Computer Vision с нуля: от OpenCV до нейросетей на PyTorch

Комплексный курс для плавного перехода в сферу компьютерного зрения с фокусом на фундаментальную базу и практические задачи. Вы освоите классическую обработку изображений с OpenCV и NumPy, научитесь проектировать и обучать свёрточные сети на PyTorch, а также применять современные архитектуры для детекции (YOLO) и сегментации (U-Net). Программа разработана для программистов, стремящихся уверенно войти в профессию ML/CV-инженера.

Цифровое изображение и основы работы с OpenCV и NumPy

Цифровое изображение и основы работы с OpenCV и NumPy

В веб-разработке изображение часто воспринимается как готовый статический ресурс — файл в теге <img> или одномерный массив байтов ImageData.data в HTML5 Canvas. Однако в компьютерном зрении изображение — это не файл и не текстура. Это строгая математическая структура: двумерная или трёхмерная числовая матрица, в которой каждый элемент описывает интенсивность светового излучения, зафиксированного матрицей камеры.

Если вы хотите научить алгоритм находить лица, распознавать дорожные знаки или сегментировать опухоли на МРТ, первым шагом станет умение работать с этой матрицей на низком уровне.

Как компьютер видит изображение

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

В подавляющем большинстве задач компьютерного зрения яркость кодируется 8 битами на канал. Это означает, что одно число может принимать 28=2562^8 = 256 различных градаций: от 00 (абсолютная темнота, чёрный цвет) до 255255 (максимальная яркость, белый цвет).

Структура данных зависит от типа изображения:

  • Полутоновое (Grayscale): двумерная матрица размера H×WH \times W, где HH (Height) — количество строк (высота в пикселях), а WW (Width) — количество столбцов (ширина в пикселях). Каждый элемент — число от 0 до 255.
  • Цветное (Color): трёхмерный массив (тензор) размера H×W×CH \times W \times C, где CC (Channels) — количество цветовых каналов. Для стандартного цветного кадра C=3C = 3.

Ловушка систем координат: OpenCV против фронтенда

Для разработчика с опытом в CSS, SVG или Canvas привычна декартова система координат экрана: точка задаётся парой (x,y)(x, y), где xx — смещение по горизонтали вправо, а yy — смещение по вертикали вниз.

В библиотеках научных вычислений (NumPy, PyTorch) и компьютерного зрения (OpenCV) изображение хранится как стандартная матрица. В матричной нотации первый индекс всегда указывает на строку (row), а второй — на столбец (column).

Строка матрицы — это вертикальная координата yy, а столбец — горизонтальная координата xx.

В математике и NumPy координаты пикселя записываются как img[y, x], а не img[x, y]. Сначала указывается вертикаль (высота), затем — горизонталь (ширина).

Если в коде перепутать эти индексы, программа либо аварийно завершится с ошибкой IndexError, либо незаметно транспонирует изображение, повернув его на 90 градусов с зеркальным отражением.

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

Установка окружения и чтение первого кадра

Для практической работы в машинном зрении на Python стандартом де-факто являются библиотеки numpy и opencv-python.

Установить их можно командой терминала:

pip install opencv-python numpy

Загрузим изображение с диска и изучим его форму:

import cv2

# Чтение изображения
img = cv2.imread('photo.jpg')

# Проверка типа и размерности
print(type(img))        # <class 'numpy.ndarray'>
print(img.shape)        # Например, (720, 1280, 3) -> (H, W, C)
print(img.dtype)        # uint8 (unsigned 8-bit integer)

Метод cv2.imread() возвращает обычный массив NumPy (ndarray). Это ключевой концепт: для OpenCV изображение не является специфическим закрытым объектом — это открытый массив чисел, к которому применимы любые матричные операции.

Загадка BGR: почему лица становятся синими

Если вы попытаетесь отобразить загруженное через OpenCV изображение в популярной библиотеке matplotlib или передать его напрямую в веб-интерфейс, картинка приобретёт странный неестественный оттенок: лица людей станут синими, а небо — оранжевым.

Причина в историческом соглашении:

  1. Общепринятый цифровой стандарт — RGB (Red, Green, Blue). В таком порядке каналы хранятся в веб-форматах (PNG, JPEG, CSS rgb()).
  2. OpenCV создавался в начале 2000-х годов, когда производители камер и разработчики формата Windows Bitmap (BMP) оптимизировали память под порядок BGR (Blue, Green, Red).

OpenCV по умолчанию читает и сохраняет файлы в порядке BGR:

  • Канал 0: Синий (Blue)
  • Канал 1: Зелёный (Green)
  • Канал 2: Красный (Red)

Чтобы привести изображение к стандартному формату RGB, используется функция преобразования цветовых пространств:

# Конвертация из BGR в RGB
img_rgb = cv2.cvtColor(img, cv2.COLOR_BGR2RGB)

# Конвертация в полутоновое изображение (Grayscale)
img_gray = cv2.cvtColor(img, cv2.COLOR_BGR2GRAY)
print(img_gray.shape)   # (720, 1280) — третий канал исчез

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

Y=0.299R+0.587G+0.114BY = 0.299 \cdot R + 0.587 \cdot G + 0.114 \cdot B

Здесь:

  • YY — результирующая яркость пикселя от 0 до 255.
  • R,G,BR, G, B — значения соответствующих каналов исходного пикселя.
  • Коэффициенты 0.2990.299, 0.5870.587 и 0.1140.114 отражают относительный вклад длин волн в восприятие яркости человеком.

Пример: если пиксель чисто синий (R=0,G=0,B=255R=0, G=0, B=255), его яркость в градациях серого составит всего 0.114×255290.114 \times 255 \approx 29 (тёмно-серый), а чисто зелёный (R=0,G=255,B=0R=0, G=255, B=0) даст 0.587×2551500.587 \times 255 \approx 150 (светло-серый).

Базовые манипуляции через срезы NumPy

Поскольку изображение — это массив NumPy, любые трансформации областей интереса (Region of Interest, ROI) выполняются через стандартный синтаксис срезов (slicing) без необходимости писать медленные циклы for.

Кадрирование (Crop)

Чтобы вырезать фрагмент изображения, достаточно указать диапазон строк по yy и диапазон столбцов по xx:

# Вырезаем прямоугольник: от y=100 до y=300, от x=250 до x=600
roi = img[100:300, 250:600]

Разделение и объединение каналов

Работать с отдельными каналами можно напрямую через индексы третьего измерения:

# Извлечение только синего канала (BGR)
blue_channel = img[:, :, 0]

# Обнуление красного канала (индекс 2)
img_no_red = img.copy()
img_no_red[:, :, 2] = 0

Арифметика над изображениями и опасность переполнения uint8

Один из важнейших аспектов при переходе от высокоуровневого веба к низкоуровневой обработке матриц — контроль типов данных. Массивы изображений хранятся с типом np.uint8 (беззнаковое 8-битное целое).

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

import numpy as np

# Прямая арифметика NumPy (по модулю 256)
a = np.uint8([250])
b = np.uint8([20])
print(a + b)  # Выведет [14], так как (250 + 20) % 256 = 14

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

Для безопасной работы с яркостью и контрастом применяют два подхода:

Подход Как работает Пример кода
Насыщающая арифметика OpenCV Значения автоматически ограничиваются диапазоном [0,255][0, 255]: всё, что выше 255, становится 255; всё, что ниже 0, становится 0 cv2.add(img, 50)
Каст во float32 с последующим clip Массив переводится в числа с плавающей точкой, вычисляются формулы, затем значения обрезаются и возвращаются в uint8 np.clip(img.astype(np.float32) * 1.5, 0, 255).astype(np.uint8)

Формула линейной коррекции контраста и яркости имеет вид:

g(x,y)=αf(x,y)+βg(x, y) = \alpha \cdot f(x, y) + \beta

Здесь:

  • f(x,y)f(x, y) — исходное значение пикселя.
  • α>0\alpha > 0 — коэффициент контрастности (α>1\alpha > 1 повышает контраст, α<1\alpha < 1 снижает).
  • β\beta — смещение яркости (β>0\beta > 0 делает светлее, β<0\beta < 0 темнее).
  • g(x,y)g(x, y) — итоговое значение пикселя.

Пример: при α=1.2\alpha = 1.2 и β=30\beta = 30 пиксель с исходной яркостью 100100 получит новое значение: 1.2×100+30=1501.2 \times 100 + 30 = 150.

Изменение размера и геометрические трансформации

В задачах глубокого обучения входные слои нейронных сетей требуют фиксированного размера тензора (например, 224×224224 \times 224 или 640×640640 \times 640 пикселей). Изменение размера (resizing) — обязательная стадия предобработки.

При изменении размера сетка пикселей пересчитывается с помощью интерполяции:

# Изменение размера до ширины 640 и высоты 480
# ВНИМАНИЕ: OpenCV в аргументе dsize ожидает (width, height), то есть (x, y)!
resized = cv2.resize(img, (640, 480), interpolation=cv2.INTER_LINEAR)

Основные методы интерполяции в OpenCV:

  • cv2.INTER_NEAREST — интерполяция по ближайшему соседу. Самая быстрая, но создаёт ступенчатые артефакты (пикселизацию). Применяется для масок сегментации, где нельзя создавать промежуточные классы.
  • cv2.INTER_LINEAR — билинейная интерполяция. Базовый стандарт для увеличения и уменьшения изображений общего назначения.
  • cv2.INTER_AREA — интерполяция с передискретизацией на основе площади пикселей. Даёт наилучший результат без муара при уменьшении разрешения.
  • cv2.INTER_CUBIC — бикубическая интерполяция по 4×44 \times 4 окрестности. Даёт более плавные границы при увеличении, но требует больше вычислительных ресурсов.

Сквозной пайплайн предобработки

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

import cv2
import numpy as np

def preprocess_for_model(image_path: str, target_size=(224, 224)) -> np.ndarray:
    # 1. Загрузка исходного кадра (BGR, uint8)
    img_bgr = cv2.imread(image_path)
    if img_bgr is None:
        raise FileNotFoundError(f"Файл {image_path} не найден")

    # 2. Определение размеров (H, W)
    h, w = img_bgr.shape[:2]

    # 3. Центральное кадрирование (Central Square Crop)
    min_dim = min(h, w)
    start_y = (h - min_dim) // 2
    start_x = (w - min_dim) // 2
    cropped = img_bgr[start_y:start_y + min_dim, start_x:start_x + min_dim]

    # 4. Приведение к RGB
    img_rgb = cv2.cvtColor(cropped, cv2.COLOR_BGR2RGB)

    # 5. Масштабирование до целевого разрешения модели
    resized = cv2.resize(img_rgb, target_size, interpolation=cv2.INTER_AREA)

    # 6. Нормализация значений в диапазон [0.0, 1.0] (float32)
    normalized = resized.astype(np.float32) / 255.0

    return normalized

# Пример вызова
processed_tensor = preprocess_for_model('photo.jpg')
print("Форма тензора:", processed_tensor.shape)  # (224, 224, 3)
print("Диапазон значений:", processed_tensor.min(), "-", processed_tensor.max())  # 0.0 - 1.0

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

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

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

Если в веб-разработке свойство filter: blur(5px) или поиск контуров на холсте canvas воспринимаются как встроенная магия браузера, то в машинном зрении за каждым подобным эффектом стоит строгая матричная операция. Любая камера фиксирует лишь плоскую сетку чисел. Каким образом алгоритм без участия нейросетей понимает, где на фотографии край документа, как отделить круглую монету от стола или сопоставить два кадра одной и той же улицы, снятых под разными углами?

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


Пространственная фильтрация и двумерная свёртка

Чтобы извлечь из матрицы пикселей структуру или убрать паразитный шум матрицы камеры, применяют операцию двумерной свёртки (2D convolution).

Идея свёртки проста: небольшая матрица фиксированного размера (обычно нечётного: 3×33 \times 3, 5×55 \times 5 или 7×77 \times 7), называемая ядром фильтра (kernel), скользит по исходному изображению слева направо и сверху вниз. Для каждого положения ядра вычисляется сумма поэлементных произведений его весов на значения перекрываемых пикселей исходного кадра.

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

S(x,y)=u=kkv=kkI(xu,yv)K(u,v)S(x, y) = \sum_{u=-k}^{k} \sum_{v=-k}^{k} I(x - u, y - v) \cdot K(u, v)

Пояснение элементов формулы:

  • S(x,y)S(x, y) — итоговое значение яркости пикселя в результирующем изображении.
  • I(xu,yv)I(x - u, y - v) — значение исходного пикселя в окрестности точки (x,y)(x, y).
  • K(u,v)K(u, v) — коэффициент (вес) ядра фильтра в относительной позиции (u,v)(u, v).
  • kk — полуширина ядра (например, для ядра 3×33 \times 3 значение k=1k = 1, а индексы u,vu, v принимают значения 1,0,1-1, 0, 1).

Практический пример: если ядро 3×33 \times 3 заполнено единицами, делёнными на 9:

K=19[111111111]K = \frac{1}{9} \begin{bmatrix} 1 & 1 & 1 \\ 1 & 1 & 1 \\ 1 & 1 & 1 \end{bmatrix}

то каждый результирующий пиксель станет средним арифметическим девяти своих соседей. Это усредняющий фильтр (Box Blur).

import cv2
import numpy as np

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

# 1. Прямое применение произвольного ядра через filter2D
kernel_box = np.ones((3, 3), dtype=np.float32) / 9.0
blurred_box = cv2.filter2D(image, ddepth=-1, kernel=kernel_box)

Гауссово и медианное размытие

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

G(u,v)=12πσ2eu2+v22σ2G(u, v) = \frac{1}{2\pi\sigma^2} e^{-\frac{u^2 + v^2}{2\sigma^2}}

Параметр σ\sigma (сигма) задаёт стандартное отклонение: чем больше σ\sigma, тем сильнее размытие и тем шире окрестность, влияющая на результат.

Однако как усредняющий, так и гауссов фильтры бессильны против импульсного шума («битые пиксели» или «соль и перец» — случайные чёрные и белые точки). Среднее арифметическое неизбежно размажет этот экстремальный выброс по всей окрестности.

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

Тип фильтра Принцип расчёта Сильная сторона Слабая сторона
Box Filter Среднее арифметическое Быстродействие Артефакты, размытие краёв
Gaussian Blur Взвешенное среднее по Гауссу Естественное сглаживание высокочастотного шума Размывает резкие границы
Median Filter Порядковая статистика (медиана) Идеален против импульсного шума Вычислительно тяжелее линейных фильтров
# 2. Гауссово размытие (размер ядра 5x5, sigma вычисляется автоматически)
gaussian = cv2.GaussianBlur(image, ksize=(5, 5), sigmaX=1.5)

# 3. Медианная фильтрация (размер апертуры 5)
median = cv2.medianBlur(image, ksize=5)

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

Сглаживание убирает шум, но как заставить компьютер обнаружить контур объекта? Граница в компьютерном зрении — это место резкого изменения яркости.

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

IxI(x+1,y)I(x1,y)\frac{\partial I}{\partial x} \approx I(x+1, y) - I(x-1, y)

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

Оператор использует два раздельных ядра: GxG_x для поиска вертикальных границ (перепад по горизонтали) и GyG_y для горизонтальных границ:

Gx=[101202101],Gy=[121000121]G_x = \begin{bmatrix} -1 & 0 & 1 \\ -2 & 0 & 2 \\ -1 & 0 & 1 \end{bmatrix}, \quad G_y = \begin{bmatrix} -1 & -2 & -1 \\ 0 & 0 & 0 \\ 1 & 2 & 1 \end{bmatrix}

Для каждого пикселя вычисляются две величины:

  1. Магнитуда градиента (сила перепада яркости):

    G=Gx2+Gy2G = \sqrt{G_x^2 + G_y^2}

  2. Направление градиента (угол вектора максимального роста яркости):

    θ=arctan(GyGx)\theta = \arctan\left(\frac{G_y}{G_x}\right)

Направление градиента всегда строго перпендикулярно направлению границы: если граница идёт вертикально, градиент направлен строго горизонтально.

# Вычисление градиентов Собеля в формате float64 (во избежание усечения отрицательных чисел)
sobel_x = cv2.Sobel(image, cv2.CV_64F, dx=1, dy=0, ksize=3)
sobel_y = cv2.Sobel(image, cv2.CV_64F, dx=0, dy=1, ksize=3)

# Расчёт полной магнитуды и перевод в uint8
magnitude = np.sqrt(sobel_x**2 + sobel_y**2)
magnitude = np.clip(magnitude, 0, 255).astype(np.uint8)

Детектор границ Кэнни (Canny Edge Detector)

Сырая магнитуда градиента Собеля даёт размытые, «толстые» границы в несколько пикселей. В 1986 году Джон Кэнни сформулировал многостадийный алгоритм, который до сих пор является стандартом детекции границ.

Алгоритм Кэнни состоит из 4 последовательных этапов:

  1. Гауссова фильтрация: первичное подавление высокочастотного шума.
  2. Расчёт магнитуды и направления градиента: вычисление GG и θ\theta через ядра Собеля.
  3. Подавление немаксимумов (Non-Maximum Suppression): утончение границ до толщины ровно в 1 пиксель. Пиксель сохраняется только в том случае, если его магнитуда строго больше, чем у двух его соседей вдоль направления градиента θ\theta.
  4. Двойная пороofficeговая фильтрация и гистерезис (Hysteresis Thresholding):
    • Пиксели с магнитудой выше верхнего порога ThighT_{high} признаются сильными границами.
    • Пиксели ниже нижнего порога TlowT_{low} отбрасываются.
    • Пиксели между TlowT_{low} и ThighT_{high} признаются границами только тогда, когда они цепочкой соединены с сильными границами.
# Детектор Кэнни с порогами 50 (нижний) и 150 (верхний)
edges = cv2.Canny(image, threshold1=50, threshold2=150)

Морфологические операции над бинарными масками

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

Морфологические операции опираются на структурирующий элемент (ядро формы прямоугольника, эллипса или креста):

  1. Эрозия (Erosion): пиксель на выходе равен 1, только если структурирующий элемент целиком помещается внутри единичной области. Эрозия «съедает» границы объектов и уничтожает мелкие изолированные точки.
  2. Дилатация (Dilation / Расширение): пиксель равен 1, если структурирующий элемент пересекает хотя бы один белый пиксель. Дилатация расширяет границы объектов и затягивает мелкие внутренние дыры.

Комбинации этих двух базовых операций дают мощные инструменты очистки:

  • Открытие (Opening = Эрозия \to Дилатация): удаляет мелкий шум на фоне, практически не меняя размеры основных крупных объектов.
  • Закрытие (Closing = Дилатация \to Эрозия): склеивает близко расположенные контуры и устраняет мелкие дыры внутри объектов.
# Создание структурирующего элемента 5x5 в форме эллипса
kernel_morph = cv2.getStructuringElement(cv2.MORPH_ELLIPSE, (5, 5))

# Применение операций
eroded = cv2.erode(edges, kernel_morph, iterations=1)
dilated = cv2.dilate(edges, kernel_morph, iterations=1)

# Составные операции через cv2.morphologyEx
opened = cv2.morphologyEx(edges, cv2.MORPH_OPEN, kernel_morph)
closed = cv2.morphologyEx(edges, cv2.MORPH_CLOSE, kernel_morph)

Детекция углов и локальных признаков: от границ к Harris и ORB

Границы позволяют сегментировать геометрию объекта, но непригодны для отслеживания (tracking) или совмещения двух кадров (panoramic stitching, SLAM). Причина — проблема апертуры: если посмотреть на прямолинейный отрезок границы через маленькое окно, невозможно понять, сместился ли объект вдоль линии границы.

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

Детектор углов Харриса (Harris Corner Detector)

Крис Харрис и Майк Стивенс предложили математический критерий угла. Если сдвинуть аналитическое окно на величину (Δx,Δy)(\Delta x, \Delta y), изменение интенсивности описывается матрицей структуры (второго момента) MM:

M=(x,y)W[Ix2IxIyIxIyIy2]M = \sum_{(x, y) \in W} \begin{bmatrix} I_x^2 & I_x I_y \\ I_x I_y & I_y^2 \end{bmatrix}

где Ix,IyI_x, I_y — частные производные яркости.

Собственные числа λ1,λ2\lambda_1, \lambda_2 матрицы MM показывают характер поверхности:

  • λ10,λ20\lambda_1 \approx 0, \lambda_2 \approx 0 — плоская однородная область.
  • λ10,λ20\lambda_1 \gg 0, \lambda_2 \approx 0 (или наоборот) — край (граница).
  • λ10,λ20\lambda_1 \gg 0, \lambda_2 \gg 0угол (перепад яркости по обеим осям).

Харрис ввёл отклик детектора RR, не требующий прямого вычисления собственных чисел:

R=det(M)k(trace(M))2R = \det(M) - k \cdot (\operatorname{trace}(M))^2

Пояснение элементов:

  • det(M)=λ1λ2\det(M) = \lambda_1 \lambda_2 — определитель матрицы MM.
  • trace(M)=λ1+λ2\operatorname{trace}(M) = \lambda_1 + \lambda_2 — след матрицы MM.
  • kk — эмпирический коэффициент (обычно от 0.04 до 0.06).
  • Область признаётся углом при R>0R > 0 со значительной величиной.
# Детекция углов Харриса
# blockSize = 2 (размер окрестности), ksize = 3 (размер ядра Собеля), k = 0.04
harris_response = cv2.cornerHarris(image, blockSize=2, ksize=3, k=0.04)

# Выделение углов: нормализация отклика и пороговая фильтрация
output_canvas = cv2.cvtColor(image, cv2.COLOR_GRAY2BGR)
output_canvas[harris_response > 0.01 * harris_response.max()] = [0, 0, 255]  # Красные точки

Дескрипторы признаков: инвариантность к масштабу и повороту (ORB)

Углы Харриса отлично работают при сдвиге камеры, но ломаются при сильном изменении масштаба (при приближении острый угол превращается в плавную дугу и перестаёт детектироваться) или значительном повороте.

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

  • Детектор находит устойчивые координаты (x,y)(x, y) в пространстве масштабов (scale-space).
  • Дескриптор преобразует локальную окрестность этой точки в числовой вектор-сигнатуру (бинарную или вещественную).

В современной классической практике часто используют ORB (Oriented FAST and Rotated BRIEF) — быстрый, устойчивый к вращению и свободный от патентных ограничений алгоритм.

# Инициализация детектора ORB
orb = cv2.ORB_create(nfeatures=500)

# Поиск ключевых точек и расчёт дескрипторов
keypoints, descriptors = orb.detectAndCompute(image, mask=None)

# descriptors — это массив формы (N, 32) типа uint8 (256 бит на каждую точку)
print(f"Найдено ключевых точек: {len(keypoints)}")
if descriptors is not None:
    print(f"Форма матрицы дескрипторов: {descriptors.shape}")

Полученные бинарные дескрипторы можно мгновенно сопоставлять между двумя изображениями через расстояние Хэмминга (cv2.BFMatcher(cv2.NORM_HAMMING)), определяя сдвиг, угол поворота или наличие конкретного объекта в кадре.


Почему классического пайплайна стало недостаточно

Классические алгоритмы — Собель, Кэнни, Харрис, ORB — построены на жёстко спроектированных математических правилах (handcrafted features). Инженер сам определял, какие ядра свёртки использовать:

  • ядро Гаусса для сглаживания;
  • ядро Собеля для дифференцирования;
  • бинарные проверки вокруг ключевой точки для дескриптора.

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

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

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

Введение в Deep Learning и фреймворк PyTorch

Введение в Deep Learning и фреймворк PyTorch

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

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

От массивов NumPy к тензорам PyTorch

Для веб-разработчика перенос логики из JavaScript или обычного Python в Deep Learning часто ассоциируется с переходом от обычных массивов к типизированным буферам вроде Float32Array. В машинном обучении фундаментальной структурой данных выступает тензор (torch.Tensor).

Тензор — это многомерный массив числовых значений с единым типом данных, поддерживающий автоматическое дифференцирование и аппаратное ускорение на специализированных чипах (CUDA GPU, Apple MPS).

Тензор PyTorch концептуально повторяет numpy.ndarray, но имеет два критических отличия:

  1. Привязка к устройству (Device management): тензор может храниться как в оперативной памяти CPU, так и в видеопамяти GPU.
  2. След вычислений (Autograd): тензор умеет хранить историю операций над собой для автоматического вычисления производных.

В компьютерном зрении на PyTorch стандартным представлением партии изображений является формат NCHW:

  • N (Batch size) — количество изображений в одном пакете (батче).
  • C (Channels) — количество цветовых каналов (например, 3 для RGB или 1 для градаций серого).
  • H (Height) — высота матрицы в пикселях.
  • W (Width) — ширина матрицы в пикселях.

Это отличается от привычного формата OpenCV и NumPy, где изображение хранится как HWC (высота, ширина, каналы). При передаче картинки в нейросеть оси необходимо транспонировать.

import torch
import numpy as np

# Проверка доступности аппаратного ускорителя
device = torch.device("cuda" if torch.cuda.is_available() else "cpu")

# Преобразование массива NumPy (HWC) в тензор PyTorch (NCHW)
raw_image = np.zeros((480, 640, 3), dtype=np.float32)

# 1. Смена порядка осей: (H, W, C) -> (C, H, W)
tensor_chw = torch.from_numpy(raw_image).permute(2, 0, 1)

# 2. Добавление размерности батча: (C, H, W) -> (1, C, H, W)
tensor_nchw = tensor_chw.unsqueeze(0).to(device)

print(f"Форма тензора: {tensor_nchw.shape}, Устройство: {tensor_nchw.device}")

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

Автоматическое дифференцирование: механизм Autograd

Обучение модели сводится к поиску ответа на вопрос: «Как изменится общая ошибка предсказания, если мы слегка изменим конкретный вес ww?». Математически это выражается частной производной:

Lw\frac{\partial L}{\partial w}

где:

  • LL — скалярное значение ошибки (Loss), показывающее величину промаха модели.
  • ww — настраиваемый параметр (вес) внутри математического преобразования.
  • Lw\frac{\partial L}{\partial w} — градиент ошибки по весу: если значение положительное, увеличение веса увеличивает ошибку; если отрицательное — увеличение веса ошибку уменьшает.

Практический пример: если для веса w=2.0w = 2.0 градиент Lw=0.5\frac{\partial L}{\partial w} = 0.5, это означает, что увеличение ww на небольшую величину вызовет рост ошибки со скоростью 0.50.5. Чтобы уменьшить ошибку, значение веса необходимо сдвинуть в противоположную сторону.

PyTorch использует динамический вычислительный граф (Dynamic Computational Graph). В момент выполнения операций над тензорами с флагом requires_grad=True фреймворк строит направленный ациклический граф, вершинами которого являются тензоры, а рёбрами — математические операторы.

Когда мы вызываем метод .backward() для итогового скаляра ошибки, PyTorch проходит по этому графу в обратном направлении (алгоритм Backpropagation) по правилу дифференцирования сложной функции (Chain Rule) и сохраняет вычисленные градиенты в свойстве .grad каждого листового тензора.

# Создаем обучаемый вес
w = torch.tensor([2.0], requires_grad=True)
b = torch.tensor([1.0], requires_grad=True)
x = torch.tensor([3.0])

# Прямой проход (Forward pass)
y = w * x + b  # y = 2.0 * 3.0 + 1.0 = 7.0
loss = (y - 10.0) ** 2  # loss = (7.0 - 10.0)^2 = 9.0

# Обратный проход (Backward pass)
loss.backward()

# Градиенты dl/dw и dl/db
print(f"dL/dw: {w.grad.item()}")  # 2 * (7 - 10) * 3 = -18.0
print(f"dL/db: {b.grad.item()}")  # 2 * (7 - 10) * 1 = -6.0

По умолчанию PyTorch накапливает (суммирует) градиенты в свойстве .grad при каждом вызове .backward(). Поэтому перед каждым новым шагом обучения накопленные значения необходимо явно сбрасывать в ноль.

Градиентный спуск и оптимизаторы

Получив градиенты всех параметров, их необходимо обновить. Базовый алгоритм оптимизации — стохастический градиентный спуск (SGD). Формула обновления весов выглядит следующим образом:

wnew=woldηLww_{new} = w_{old} - \eta \cdot \frac{\partial L}{\partial w}

где:

  • woldw_{old} — текущее значение веса.
  • wneww_{new} — обновлённое значение веса на следующем шаге.
  • Lw\frac{\partial L}{\partial w} — вычисленный градиент функции потерь по данному весу.
  • η\eta (learning rate, α\alpha) — темп обучения, гиперпараметр, определяющий величину шага оптимизатора в пространстве параметров.

Практический пример: если текущий вес wold=5.0w_{old} = 5.0, градиент Lw=4.0\frac{\partial L}{\partial w} = 4.0, а темп обучения установлен в η=0.01\eta = 0.01, то новый вес составит 5.00.01×4.0=4.965.0 - 0.01 \times 4.0 = 4.96.

Выбор темпа обучения (η\eta) критически влияет на процесс оптимизации:

  • Слишком большой η\eta: модель совершает слишком широкие шаги, перескакивает локальный минимум и расходится (Loss стремится к бесконечности или NaN).
  • Слишком малый η\eta: процесс обучения требует сотен тысяч итераций и может застрять в неглубоком плато функции ошибки.
  • Оптимальный η\eta: функция потерь стабильно и плавно снижается к минимуму.
Оптимизатор Принцип работы Преимущества Основная область применения
SGD with Momentum Учитывает инерцию предыдущих шагов (накопление импульса) Хорошо преодолевает локальные минимумы и седловые точки Классические сверточные сети, финальная полировка качества моделей
Adam (Adaptive Moment Estimation) Адаптирует шаг для каждого параметра отдельно на основе средних и дисперсий градиентов Устойчив к выбору гиперпараметров, быстро сходится на старте Универсальный выбор по умолчанию для большинства CV-задач

Архитектура модели: модуль nn.Module

В PyTorch любая нейросеть оформляется как класс, наследуемый от torch.nn.Module. Конструкция состоит из двух обязательных элементов:

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

Для преобразования плоских признаков используется линейный слой (nn.Linear), вычисляющий операцию y=xWT+by = xW^T + b. Если между линейными слоями не ставить функции активации, сколь угодно глубокая сеть останется эквивалентна одной простой линейной регрессии.

Функция активации ReLU (Rectified Linear Unit), задаваемая как f(x)=max(0,x)f(x) = \max(0, x), вносит в модель нелинейность, отсекая отрицательные значения и пропуская положительные без изменений.

import torch.nn as nn

class SimplePerceptron(nn.Module):
    def __init__(self, input_dim: int, hidden_dim: int, num_classes: int):
        super().__init__()
        # Полносвязный слой: матрица весов (input_dim -> hidden_dim)
        self.layer1 = nn.Linear(input_dim, hidden_dim)
        # Нелинейная функция активации
        self.relu = nn.ReLU()
        # Выходной слой для предсказания классов
        self.layer2 = nn.Linear(hidden_dim, num_classes)

    def forward(self, x: torch.Tensor) -> torch.Tensor:
        # x имеет форму (N, input_dim)
        x = self.layer1(x)
        x = self.relu(x)
        logits = self.layer2(x)
        return logits

Выходом модели являются логиты (logits) — сырые некалиброванные вещественные числа. Для задач классификации их сопоставляют с истинными метками через функцию потерь nn.CrossEntropyLoss, которая внутри себя объединяет математическую операцию Softmax (превращение логитов в распределение вероятностей) и отрицательное логарифмическое правдоподобие (NLLLoss).

Полный цикл обучения (Training Loop)

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

import torch
import torch.nn as nn
import torch.optim as optim

# Инициализация синтетических данных (100 примеров, 10 признаков, 2 класса)
X = torch.randn(100, 10)
y = torch.randint(0, 2, (100,))

# Создание модели, функции потерь и оптимизатора
model = SimplePerceptron(input_dim=10, hidden_dim=16, num_classes=2)
criterion = nn.CrossEntropyLoss()
optimizer = optim.Adam(model.parameters(), lr=0.01)

# Цикл обучения на 5 эпох
model.train()
for epoch in range(5):
    # 1. Сброс градиентов с предыдущего шага
    optimizer.zero_grad()

    # 2. Прямой проход (Forward pass)
    outputs = model(X)

    # 3. Расчёт величины ошибки
    loss = criterion(outputs, y)

    # 4. Обратное распространение ошибки (Backward pass)
    loss.backward()

    # 5. Корректировка параметров модели
    optimizer.step()

    print(f"Эпоха {epoch + 1}, Ошибка (Loss): {loss.item():.4f}")

Последовательность внутри цикла zero_grad -> forward -> loss -> backward -> step — фундаментальный инвариант PyTorch, который остаётся неизменным вне зависимости от сложности модели: от простейшего классификатора до современных трансформеров компьютерного зрения.