Skip to content

Folders and files

NameName
Last commit message
Last commit date

Latest commit

 

History

4 Commits
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

Лабораторная работа №1

Для 1 лаб работы по CV необходимо реализовать базовый минимум операций над изображениями Входное изображение в формате (RGB, не чёрно-белое)

  1. Фильтры 1.1 Медианный фильтр 1.2 Фильтр гаусса
  2. Морфологические операции 2.1 Эрозия 2.2 Дилатация
  3. Прочие операции 3.1 пороговая бинаризация (для rgb и grayscale изображения) 3.2 выравнивание гистограммы 3.3 поворот изображений на угол кратный 90 градусов

Использовать методы OpenCV для реализации операций нельзя. Допустимы только методы cv2.imread() и cv2.imshow(). Все методы должны быть реализованы вручную.


Реализованные методы

1. Медианный фильтр

Назначение: подавление импульсных шумов («соль и перец») при сохранении границ.

Алгоритм:

  1. Применить pad_reflect с отступом ksize // 2.
  2. Для каждого пикселя (i, j) извлечь окно ksize × ksize.
  3. Заменить значение пикселя медианой элементов окна.
def median_filter_gray(img_gray, ksize=3):
    assert ksize % 2 == 1
    h, w = img_gray.shape
    ph = ksize // 2
    padded = pad_reflect(img_gray, ph, ph)
    out = np.empty_like(img_gray)
    for i in range(h):
        for j in range(w):
            window = padded[i:i+ksize, j:j+ksize].ravel()
            out[i, j] = np.median(window)
    return out

def median_filter_rgb(img_rgb, ksize=3):
    channels = []
    for c in range(3):
        channels.append(median_filter_gray(img_rgb[..., c], ksize))
    return np.stack(channels, axis=2)

В конвейере используется ksize=7 для цветного и ksize=3 для grayscale.

Входное изображение:

original_rgb

После медианного фильтра (RGB, ksize=7):

median_rgb

После медианного фильтра (grayscale, ksize=3):

median_gray


2. Гауссов фильтр

Назначение: размытие / шумоподавление с плавным взвешиванием соседей.

Ядро Гаусса размером k×k с параметром σ:

$$K(x, y) = \frac{1}{2\pi\sigma^2} \exp!\left(-\frac{x^2+y^2}{2\sigma^2}\right)$$

Ядро нормируется так, чтобы сумма всех элементов равнялась 1:

def gaussian_kernel(ksize=5, sigma=1.0):
    assert ksize % 2 == 1
    ax = np.arange(-ksize//2 + 0.0, ksize//2 + 1.0)
    xx, yy = np.meshgrid(ax, ax)
    kernel = np.exp(-(xx**2 + yy**2) / (2.0 * sigma**2))
    kernel = kernel / np.sum(kernel)
    return kernel

Свёртка (grayscale):

def convolve_gray(img_gray, kernel):
    k = kernel.shape[0]
    ph = k // 2
    h, w = img_gray.shape
    padded = pad_reflect(img_gray, ph, ph).astype(np.float64)
    out = np.zeros((h, w), dtype=np.float64)
    for i in range(h):
        for j in range(w):
            window = padded[i:i+k, j:j+k]
            out[i, j] = np.sum(window * kernel)
    return np.clip(out, 0, 255).astype(np.uint8)

def gaussian_filter_rgb(img_rgb, ksize=5, sigma=1.0):
    kern = gaussian_kernel(ksize, sigma)
    chans = [convolve_gray(img_rgb[..., c], kern) for c in range(3)]
    return np.stack(chans, axis=2)

После гауссового фильтра (RGB, ksize=5, σ=1.0):

gaussian_rgb

После гауссового фильтра (grayscale):

gaussian_gray


3. Морфологическая эрозия

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

Для каждого пикселя берётся минимум значений в окне, заданном структурным элементом se:

def erosion_gray(img_gray, se=None):
    if se is None:
        se = np.ones((3,3), dtype=bool)
    k = se.shape[0]
    ph = k // 2
    h, w = img_gray.shape
    padded = pad_reflect(img_gray, ph, ph)
    out = np.empty_like(img_gray)
    for i in range(h):
        for j in range(w):
            window = padded[i:i+k, j:j+k]
            out[i,j] = np.min(window[se])
    return out

def erosion_rgb(img_rgb, se=None):
    chans = [erosion_gray(img_rgb[..., c], se) for c in range(3)]
    return np.stack(chans, axis=2)

Используется квадратный структурный элемент 3×3 из единиц.

После эрозии (grayscale):

erosion_gray

После эрозии (RGB):

erosion_rgb


4. Морфологическое расширение (дилатация)

Назначение: расширение светлых областей, «закрытие» мелких тёмных дыр.

Симметрично эрозии, но берётся максимум:

def dilation_gray(img_gray, se=None):
    if se is None:
        se = np.ones((3,3), dtype=bool)
    k = se.shape[0]
    ph = k // 2
    h, w = img_gray.shape
    padded = pad_reflect(img_gray, ph, ph)
    out = np.empty_like(img_gray)
    for i in range(h):
        for j in range(w):
            window = padded[i:i+k, j:j+k]
            out[i,j] = np.max(window[se])
    return out

def dilation_rgb(img_rgb, se=None):
    chans = [dilation_gray(img_rgb[..., c], se) for c in range(3)]
    return np.stack(chans, axis=2)

После дилатации (grayscale):

dilation_gray

После дилатации (RGB):

dilation_rgb


5. Пороговая бинаризация

Назначение: сегментация — разделение пикселей на «тёмные» и «светлые».

Перевод в grayscale по стандартной формуле яркости:

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

def rgb_to_gray(img_rgb):
    r = img_rgb[...,0].astype(np.float32)
    g = img_rgb[...,1].astype(np.float32)
    b = img_rgb[...,2].astype(np.float32)
    y = 0.299*r + 0.587*g + 0.114*b
    return np.clip(y, 0, 255).astype(np.uint8)

Бинаризация grayscale:

def threshold_gray(img_gray, thresh=128, maxval=255, inv=False):
    if not inv:
        out = (img_gray >= thresh).astype(np.uint8) * maxval
    else:
        out = (img_gray < thresh).astype(np.uint8) * maxval
    return out

Бинаризация RGB (два режима):

def threshold_rgb(img_rgb, thresh=128, mode='per_channel'):
    if mode == 'per_channel':
        # каждый канал R, G, B бинаризуется независимо
        out = np.zeros_like(img_rgb)
        for c in range(3):
            out[..., c] = threshold_gray(img_rgb[..., c], thresh)
        return out
    elif mode == 'luminance':
        # перевод в grayscale, затем бинаризация, результат — grayscale в RGB-обёртке
        gray = rgb_to_gray(img_rgb)
        th = threshold_gray(gray, thresh)
        return np.stack([th, th, th], axis=2)
Режим Описание
threshold_gray grayscale → чёрно-белое (порог 128)
threshold_rgb (per_channel) каждый цветовой канал бинаризуется независимо
threshold_rgb (luminance) по яркости → чёрно-белое

Grayscale порог (thresh=128):

threshold_gray

RGB порог по каналам (thresh=128):

threshold_rgb_per_channel

RGB порог по яркости (thresh=128):

threshold_rgb_luminance


6. Выравнивание гистограммы

Назначение: повышение контрастности изображения за счёт равномерного перераспределения яркостей.

Для grayscale используется CDF гистограммы:

$$\text{lut}[v] = \text{round}!\left(\frac{\text{CDF}(v) - \text{CDF}_{\min}}{N - \text{CDF}_{\min}} \cdot 255\right)$$

def equalize_hist_gray(img_gray):
    hist, _ = np.histogram(img_gray.flatten(), bins=256, range=(0, 256))
    cdf = np.cumsum(hist).astype(np.float64)
    cdf_min = cdf[cdf > 0].min() if np.any(cdf > 0) else 0.0
    n = img_gray.size
    if n == cdf_min:
        lut = np.arange(256).astype(np.uint8)
    else:
        lut = np.round((cdf - cdf_min) / (n - cdf_min) * 255.0).astype(np.uint8)
    return lut[img_gray]

Для RGB используется пространство YCbCr: выравнивается только канал яркости Y, цветовые каналы Cb/Cr остаются без изменений, затем всё переводится обратно в RGB:

def equalize_hist_rgb(img_rgb):
    arr = img_rgb.astype(np.float32)
    R, G, B = arr[...,0], arr[...,1], arr[...,2]

    Y  =  0.299*R + 0.587*G + 0.114*B
    Cb = 128 + (-0.168736*R - 0.331264*G + 0.5*B)
    Cr = 128 + (0.5*R - 0.418688*G - 0.081312*B)

    Y_eq = equalize_hist_gray(np.clip(Y, 0, 255).astype(np.uint8)).astype(np.float32)

    R2 = Y_eq + 1.402 * (Cr - 128)
    G2 = Y_eq - 0.344136 * (Cb - 128) - 0.714136 * (Cr - 128)
    B2 = Y_eq + 1.772 * (Cb - 128)

    return np.clip(np.stack([R2, G2, B2], axis=2), 0, 255).astype(np.uint8)

Выравнивание только Y, а не каждого RGB-канала по отдельности, предотвращает нежелательные цветовые сдвиги.

Выравнивание гистограммы (grayscale):

1.grayscale до эквализации equalized_gray

2.grayscale после эквализации alt text

equalized_gray

Выравнивание гистограммы (RGB через YCbCr):

1.яркость (Y) RGB до эквализации alt text

2.яркость (Y) RGB после эквализации alt text

equalized_rgb


7. Повороты на кратные 90°

Назначение: геометрические преобразования без интерполяции.

Реализованы через транспонирование и зеркальное отражение массива — операции без потерь качества:

def rotate_90_multiple(img, k=1):
    k = k % 4
    if k == 0:
        return img.copy()
    if img.ndim == 2:
        if k == 1: return np.flipud(np.transpose(img))           # +90°
        if k == 2: return np.fliplr(np.flipud(img))              # 180°
        if k == 3: return np.transpose(np.flipud(img))           # -90° / +270°
    else:  # 3D (RGB)
        if k == 1: return np.flipud(np.transpose(img, (1,0,2)))
        if k == 2: return np.fliplr(np.flipud(img))
        if k == 3: return np.transpose(np.flipud(img), (1,0,2))
k Угол Операция
1 +90° Транспонирование + вертикальный флип
2 180° Вертикальный флип + горизонтальный флип
3 +270° (−90°) Вертикальный флип + транспонирование

Поворот на 90°:

rot90

Поворот на 180°:

rot180

Поворот на 270°:

rot270


Краткое сравнение методов

Метод Тип операции Влияет на цвет Обратима
Медианный фильтр Нелинейная фильтрация Нет (по каналам) Нет
Гауссов фильтр Линейная свёртка Нет (по каналам) Нет
Эрозия Морфология (min) Нет (по каналам) Нет
Дилатация Морфология (max) Нет (по каналам) Нет
Эрозия + Дилатация Opening/Closing Нет
Порог (grayscale) Сегментация Нет
Порог per_channel Сегментация Да Нет
Порог luminance Сегментация Нет Нет
Эквализация (gray) Нормировка яркости Нет
Эквализация (RGB) Нормировка яркости YCbCr Минимально Нет
Поворот ×90° Геометрия Нет Да (k=4 ≡ 0)

Лабораторная работа №2 — Визуальная одометрия (навигация)

Цель работы

Лабораторная работа №2. Визуальная одометрия (навигация) Цель: Разработать систему визуальной одометрии (навигации) по группе фотографий. Ход работы: сделайте не менее 8 фото с переносом камеры или ноутбука по квадрату (то есть двиньте сначала вправо, потом вперед, потом влево, потом назад и обратно в начальную точку). Используя данные фотографии реализуйте следующее:

  1. Определите на каждой фотографии ключевые точки

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

  3. Постройте по каждой точке дескриптор (можете использовать любой, рекомендуется SIFT)

  4. Сопоставьте два соседних изображения на предмет соответствия ключевых точек. То есть определите пары одинаковых точек.

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

  6. С учетом полученных моделей постройте траекторию движения камеры.

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

В процессе выполнения вы можете использовать готовые функции по погрузке данных, перевода в цветовые пространства, фильтрации, для построения прямых и траекторий. Функции 1-6 описанные выше должны быть реализованы самостоятельно.

import cv2                       # загрузка изображений
import numpy as np               # матричные операции, геометрия
import matplotlib
matplotlib.use('Agg')            # бэкенд без GUI-окна — рендер прямо в файл
import matplotlib.pyplot as plt
from matplotlib.collections import LineCollection  # градиентная раскраска траектории
from PIL import Image, ImageDraw
import os

Съёмка

Камера (ноутбук) перемещалась по квадрату: вправо → вперёд → влево → назад → в начальную точку.
Сделано 8 последовательных снимков.

Кадр Файл Движение
1 alt text Начальная точка
2 alt text Сдвиг вправо
3 alt text Сдвиг вправо + вперёд
4 alt text Сдвиг вперёд
5 alt text Сдвиг влево
6 alt text Сдвиг влево + назад
7 alt text Сдвиг назад
8 alt text Возврат к началу

Ориентир на изображениях — два магнита на холодильнике


Структура проекта

lab2/
├── cv_lab_2.py          ← основной скрипт
├── sequence1.jpeg
├── sequence2.jpeg
├── ...
├── sequence8.jpeg
└── output/
    ├── matches_1_2.png  ← визуализация совпадений
    ├── matches_2_3.png
    ├── matches_3_4.png
    ├── matches_4_5.png
    ├── matches_5_6.png
    ├── matches_6_7.png
    ├── matches_7_8.png
    └── camera_trajectory.png

2. Загрузка и конвертация цвета

2.1 BGR → RGB

OpenCV читает изображения в порядке каналов BGR. Переворачиваем каналы вручную:

def bgr_to_rgb(img):
    return img[..., ::-1].copy()

2.2 BGR → Grayscale (ITU-R BT.601)

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

Y = 0.114·B + 0.587·G + 0.299·R
def bgr_to_gray(img):
    return (
        0.114 * img[:, :, 0].astype(np.float64)
      + 0.587 * img[:, :, 1].astype(np.float64)
      + 0.299 * img[:, :, 2].astype(np.float64)
    ).astype(np.uint8)

[Оттенки серого] alt text


3. Выравнивание яркости — CLAHE

Что такое CLAHE?

CLAHE (Contrast Limited Adaptive Histogram Equalization) — адаптивное выравнивание гистограммы яркости. В отличие от глобального HE, изображение делится на тайлы tile_size × tile_size, и в каждом тайле гистограмма выравнивается независимо. Чтобы подавить артефакты в однородных областях, гистограмма обрезается на уровне clip_limit · (N_пикселей / 256), а «срезанный» избыток равномерно перераспределяется.

Алгоритм

для каждого тайла (tr, tc):
  1. Строим гистограмму hist[256]
  2. clip = clip_limit * n_pix / 256
  3. excess = sum(max(0, hist[b] - clip))
  4. hist = min(hist, clip)
  5. hist += excess / 256          ← равномерное перераспределение
  6. CDF → LUT (нормировка 0..255)
  7. Применяем LUT к тайлу
def clahe_manual(gray, clip_limit=2.0, tile_size=8):
    h, w = gray.shape
    out = np.zeros_like(gray, dtype=np.uint8)
    th = (h + tile_size - 1) // tile_size
    tw = (w + tile_size - 1) // tile_size
    for tr in range(th):
        for tc in range(tw):
            y0, y1 = tr * tile_size, min((tr + 1) * tile_size, h)
            x0, x1 = tc * tile_size, min((tc + 1) * tile_size, w)
            tile = gray[y0:y1, x0:x1].astype(np.int32)
            n_pix = tile.size
            hist = np.bincount(tile.ravel(), minlength=256).astype(np.float64)
            clip = clip_limit * n_pix / 256.0
            excess = np.maximum(0.0, hist - clip).sum()
            hist = np.minimum(hist, clip)
            hist += excess / 256.0
            cdf = np.cumsum(hist)
            cdf_min = cdf[cdf > 0][0]
            lut = np.round((cdf - cdf_min) / max(float(n_pix) - cdf_min, 1.0) * 255.0
                           ).clip(0, 255).astype(np.uint8)
            out[y0:y1, x0:x1] = lut[tile]
    return out
Параметр Значение Описание
clip_limit 2.0 Порог обрезания гистограммы
tile_size 8 пкс Размер тайла

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

alt text Рис. 3 — Кадр 1: оригинал → grayscale → после CLAHE.

Гистограммы яркости alt text Рис. 4 — Гистограммы яркости кадра 1 до и после CLAHE: виден «расправленный» распределение.

Все кадры CLAHE alt text Рис. 5 — Все 8 кадров после применения CLAHE.


4. Детектирование ключевых точек — DoG + ANMS

4.1 Гауссовская пирамида и DoG

DoG (Difference of Gaussians) — эффективная аппроксимация нормированного лапласиана Гаусса (LoG), использующаяся в SIFT. Вычисляется как разность двух гауссовых размытий с соседними σ:

DoG(x, y, σ) = G(x, y, k·σ) * I − G(x, y, σ) * I

Ручная реализация разделимого 1D-гауссового фильтра:

def gaussian_kernel_1d(sigma):
    radius = max(1, int(3.0 * sigma + 0.5))
    x = np.arange(-radius, radius + 1, dtype=np.float64)
    k = np.exp(-0.5 * (x / sigma) ** 2)
    return k / k.sum()

def gaussian_blur_manual(img, sigma):
    k = gaussian_kernel_1d(sigma)
    r = len(k) // 2
    img_f = img.astype(np.float64)
    padded = np.pad(img_f, [(0, 0), (r, r)], mode='reflect')
    h_out = sum(k[i] * padded[:, i:i + img_f.shape[1]] for i in range(len(k)))
    padded = np.pad(h_out, [(r, r), (0, 0)], mode='reflect')
    return sum(k[i] * padded[i:i + h_out.shape[0], :] for i in range(len(k)))

Строим пирамиду DoG для σ ∈ {1.0, 1.6, 2.5, 4.0, 6.3, 10.0} (5 карт разностей):

def build_dog_pyramid(gray, sigmas=(1.0, 1.6, 2.5, 4.0, 6.3, 10.0)):
    blurs = [gaussian_blur_manual(gray, s) for s in sigmas]
    return [blurs[i + 1] - blurs[i] for i in range(len(blurs) - 1)]

4.2 Поиск локальных максимумов

def find_local_maxima(dog, threshold=4.0, min_dist=12):
    abs_dog = np.abs(dog)
    kps = []
    step = max(1, min_dist // 2)
    for y in range(min_dist, h - min_dist, step):
        for x in range(min_dist, w - min_dist, step):
            patch = abs_dog[y-min_dist:y+min_dist, x-min_dist:x+min_dist]
            if abs_dog[y, x] == patch.max() and abs_dog[y, x] > threshold:
                kps.append((x, y, float(abs_dog[y, x])))
    return kps

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

4.3 Adaptive NMS

Для ограничения числа точек до target_n = 500 применяется Adaptive NMS (бинарный поиск минимального радиуса подавления r*):

def adaptive_nms(keypoints, target_n=400):
    kps = sorted(keypoints, key=lambda k: -k[2])   # по убыванию отклика
    lo, hi = 1.0, 2000.0
    for _ in range(25):                              # 25 итераций бинпоиска
        mid = (lo + hi) / 2.0
        selected, suppressed = [], set()
        for i in range(len(kps)):
            if i in suppressed: continue
            selected.append(kps[i])
            for j in range(i + 1, len(kps)):
                dx = kps[j][0] - kps[i][0]
                dy = kps[j][1] - kps[i][1]
                if dx*dx + dy*dy < mid*mid:
                    suppressed.add(j)
        if len(selected) >= target_n:
            lo = mid
        else:
            hi = mid
    return selected[:target_n]

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

Ключевые точки alt text

Рис. 6 — Ключевые точки (DoG + Adaptive NMS, до 500 на кадр) на всех 8 кадрах.


5. Дескриптор SIFT (ручная реализация, 128D)

Алгоритм

SIFT-дескриптор описывает локальную текстуру вокруг ключевой точки как набор взвешенных гистограмм ориентаций градиента:

  1. Градиент — центральные разности 2-го порядка:
def compute_gradient(img):
    gx[:, 1:-1] = (f[:, 2:] - f[:, :-2]) / 2.0
    gy[1:-1, :] = (f[2:, :] - f[:-2, :]) / 2.0
    return np.sqrt(gx**2 + gy**2), np.arctan2(gy, gx)
  1. Доминирующая ориентация (36-бинная гистограмма, взвешенная по |∇|) → инвариантность к повороту.

  2. 16×16 патч делится на 4×4 ячейки, в каждой — 8-бинная гистограмма → вектор 4×4×8 = 128 измерений.

  3. L2-нормировка → обрезание на 0.2 → повторная L2-нормировка → инвариантность к освещению.

def sift_descriptor_manual(eq, x, y, patch_size=16, n_cells=4, n_ori_bins=8):
    # ... извлечение патча, градиент, доминантная ориентация ...
    for r in range(n_cells):
        for c in range(n_cells):
            m_cell = mag[r*cell:(r+1)*cell, c*cell:(c+1)*cell]
            o_cell = ori_rel[r*cell:(r+1)*cell, c*cell:(c+1)*cell]
            hist = np.zeros(n_ori_bins)
            for angle, weight in zip(o_cell.ravel(), m_cell.ravel()):
                b = int((angle + np.pi) / (2*np.pi) * n_ori_bins) % n_ori_bins
                hist[b] += weight
            desc.extend(hist.tolist())
    # L2 -> clip(0.2) -> L2
    desc = np.clip(desc / np.linalg.norm(desc), 0.0, 0.2)
    return desc / np.linalg.norm(desc)

Пример дескриптора alt text

Рис. 7 — 128-мерный SIFT-дескриптор ключевой точки кадра 1: тепловая карта (слева) и вектор (справа). Красная пунктирная линия — порог клиппирования 0.2.


6. Сопоставление дескрипторов — Lowe Ratio Test

Brute-Force + Ratio Test (Lowe, 2004)

Для каждого дескриптора из кадра i находим 2 ближайших соседа в кадре i+1 (L2-расстояние). Совпадение принимается только если:

dist(d, nn1) < ratio_thresh · dist(d, nn2)

где ratio_thresh = 0.80. Это позволяет отфильтровать ненадёжные (неоднозначные) совпадения.

def match_descriptors_manual(desc1, desc2, ratio_thresh=0.80):
    diff = desc1[:, np.newaxis, :] - desc2[np.newaxis, :, :]
    dist_mat = np.sqrt((diff ** 2).sum(axis=2))
    matches = []
    for i in range(len(desc1)):
        row = dist_mat[i]
        idx = np.argsort(row)
        if len(idx) >= 2 and row[idx[0]] < ratio_thresh * row[idx[1]]:
            matches.append((i, int(idx[0]), float(row[idx[0]])))
    return matches

Визуализация: цветные линии соединяют соответствующие точки в паре кадров.

Совпадения по каждой паре кадров

Пара 1 → 2

alt text Пара 2 → 3

alt text Пара 3 → 4

alt text Пара 4 → 5

alt text Пара 5 → 6

alt text Пара 6 → 7

alt text Пара 7 → 8

alt text

На каждом рисунке: слева — кадр i, справа — кадр i+1. Цветные эллипсы — ключевые точки (детектированные DoG-блобы), цветные линии — принятые совпадения (Lowe ratio ≤ 0.80). Для наглядности отображается до 40 совпадений из всех найденных.

Сетка совпадений alt text Рис. 8 — Совпадения ключевых точек для всех 7 пар соседних кадров.

Число совпадений alt text Рис. 9 — Количество совпадений в каждой паре кадров. Красная пунктирная линия — минимальный порог для RANSAC (4 пары).


7. Оценка трансформации — RANSAC

Модель: жёсткое преобразование (Rigid Transform)

Модель «вращение + перенос» (4 степени свободы: θ, tx, ty с ограничением изотропного масштаба):

[x']   [cos θ  -sin θ | tx] [x]
[y'] = [sin θ   cos θ | ty] [y]
                             [1]

Минимальная выборка — 2 пары точек

Из двух пар точек аналитически вычисляем cos(θ), sin(θ) и t:

cos(θ) = (Δp1 · Δp2) / |Δp1|²
sin(θ) = (Δp1 × Δp2) / |Δp1|²
t = p2₀ − R · p1₀

RANSAC-цикл + уточнение на инлайерах

def estimate_rigid_ransac(kps1, kps2, matches, n_iter=2000, threshold=3.0):
    for _ in range(n_iter):
        # 1. Случайная выборка 2 пар
        idx = rng.choice(len(matches), 2, replace=False)
        # 2. Аналитическое решение
        cos_a = (dp1 @ dp2) / n1
        sin_a = np.cross(dp1, dp2) / n1
        R = [[cos_a, -sin_a], [sin_a, cos_a]]
        t = pts2[idx[0]] - R @ pts1[idx[0]]
        # 3. Подсчёт инлайеров (порог 3 пикселя)
        err = ||R @ pts1.T + t - pts2.T||mask = err < threshold
    # 4. МНК-уточнение методом Прокруста (SVD) на инлайерах
    H = (p1_in - c1).T @ (p2_in - c2)
    U, S, Vt = np.linalg.svd(H)
    R_ref = Vt.T @ U.T
    t_ref = c2 - R_ref @ c1
Параметр Значение
Число итераций n_iter 2000
Порог инлайера threshold 3.0 пкс
Уточнение SVD (метод Прокруста)

8. Траектория камеры

Накопление поз

Трансформация T_img : p' = R·p + t отображает точки из кадра i в кадр i+1. Движение камеры — обратная матрица: T_cam = T_img⁻¹. Позы накапливаются:

pose_{k+1} = pose_k · T_cam_{k→k+1}

Масштабный коэффициент SCALE = 0.01 переводит пиксельные смещения в условные единицы.

trajectory = [(0.0, 0.0, 0.0)]
pose = np.eye(3)
for M in transforms:
    if M is not None:
        M_h = np.vstack([M, [0., 0., 1.]])
        pose = pose @ np.linalg.inv(M_h)
    trajectory.append((pose[0,2]*SCALE, pose[1,2]*SCALE,
                        np.arctan2(pose[1,0], pose[0,0])))

Траектория и смещения alt text Рис. 10 — Вид сверху на траекторию камеры (слева, цвет кодирует время), вектор смещений ΔX/ΔY в пикселях (справа вверху) и угол вращения (справа внизу).


9. Результаты и выводы

Ключевые показатели

Этап Параметры Результат
Grayscale BT.601 8 кадров, 1 канал
CLAHE clip=2.0, tile=8 Выравнивание по тайлам
DoG σ: 1.0→10.0, 5 октав + ×0.5 ~300–1500 сырых точек/кадр
ANMS target=500, 25 iter ≤500 точек, равномерно
SIFT 16×16 px, 4×4×8=128D ~400–500 дескрипторов
Lowe ratio thresh=0.80 Надёжные совпадения
RANSAC 2000 iter, 3 px Жёсткая трансформация
Траектория SCALE=0.01 8 поз (x, y, θ)

Выводы

  • DoG + ANMS обеспечивает стабильное и пространственно равномерное покрытие ключевыми точками даже при неравномерной яркости.
  • CLAHE значительно улучшает работу детектора в тёмных/засвеченных областях, расширяя динамический диапазон локально.
  • Ручная реализация SIFT (без cv2.SIFT_create) подтверждает корректность алгоритма: после L2-нормировки и клиппирования дескрипторы устойчивы к аффинным изменениям яркости.
  • Ratio test Лоу (0.80) эффективно отбрасывает неоднозначные совпадения.
  • RANSAC с минимальной выборкой из 2 пар с Прокрустовым уточнением даёт точную оценку жёсткой трансформации при наличии аутлайеров.
  • Накопленная траектория наглядно отражает относительное движение камеры между соседними кадрами.

Лабораторная работа №2 — Computer Vision, визуальная одометрия

Лабораторная работа №3. Работа с видеопотоком

Цель: Научиться анализировать видеопоток.

Ход работы: получить видеопоток с Web-камеры и определить перемещающийся в кадре объект. Используя данные видеопотока реализуйте следующее:

  1. Реализуйте получение данных с Web-камеры

  2. Реализуйте алгоритм вычитания фона

  3. Реализуйте определение движущегося предмета

  4. Постройте траекторию движения объекта.

  5. Проведите тестирование на тестовом видео. (возьмите видео у преподавателя.)

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

Лабораторная работа №3. Работа с видеопотоком

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


Содержание

  1. Теоретическая часть
  2. Структура проекта
  3. Реализация
  4. Вспомогательные функции рисования
  5. Результаты
  6. Анализ ошибок и ограничений
  7. Выводы

1. Теоретическая часть

Вычитание фона (Background Subtraction)

Основная идея: если есть модель «пустого» фона B(x,y), то для каждого нового кадра I(x,y) маска переднего плана получается как:

mask(x,y) = 1,  если |I(x,y) − B(x,y)| > thresh
mask(x,y) = 0,  иначе

В работе реализованы два метода:

Метод Описание Плюсы Минусы
Статическое усреднение (sr_mod_fon) B = mean(all frames) Точный фон без объекта Требует все кадры заранее; не адаптируется
Обновляемый фон (mod_fon) B = 0.5·frame + 0.5·B Адаптируется к изменению освещения Медленно «забывает» неподвижные объекты

Центроид через моменты изображения

Нулевой момент (площадь):

m₀₀ = Σ f(x,y)

Первые моменты:

m₁₀ = Σ x·f(x,y),   m₀₁ = Σ y·f(x,y)

Координаты центроида:

cx = m₁₀ / m₀₀,   cy = m₀₁ / m₀₀

2. Структура проекта

lab3/
├── lab3.py                  # основной скрипт
├── lab1_functions.py        # rgb_to_gray, threshold_gray (из лаб. 1)
├── lab2_functions.py        # gaussian_blur_manual (из лаб. 2)
├── mouse_1.avi              # тестовое видео (~6076 кадров)
└── lab3_output/             # результаты
    ├── 3_1_initial_frames.png
    ├── 3_2_background_avg_vis.png
    ├── 3_2_background_upd_vis.png
    ├── 3_9_avg_traj_2d.png
    ├── 3_9_avg_traj_coords.png
    ├── 3_9_avg_traj_speed.png
    ├── 3_9_upd_traj_2d.png
    ├── 3_9_upd_traj_coords.png
    ├── 3_9_upd_traj_speed.png
    ├── 3_10_frame0X_original.png  (5 кадров)
    ├── 3_10_frame0X_mask.png
    ├── 3_10_frame0X_result.png
    └── 3_10_row_*.png

3. Реализация

3.1 Получение видеопотока

Видеофайл читается покадрово через cv2.VideoCapture. Каждый кадр масштабируется методом ближайшего соседа (resize_nn) для ускорения обработки, затем конвертируется в оттенки серого через lb1.rgb_to_gray.

def resize_nn(img, scale=0.5):
    """Масштабирование методом ближайшего соседа (без cv2.resize)."""
    h, w = img.shape[:2]
    nh, nw = int(h * scale), int(w * scale)
    yi = np.clip((np.arange(nh) / scale).astype(int), 0, h - 1)
    xi = np.clip((np.arange(nw) / scale).astype(int), 0, w - 1)
    return img[yi[:, None], xi[None, :], :] if img.ndim == 3 \
           else img[yi[:, None], xi[None, :]]

def read_video_frames(path, max_frames=5000, step=1, scale=0.5):
    cap = cv2.VideoCapture(path)
    fps   = cap.get(cv2.CAP_PROP_FPS) or 20.0
    frames_bgr, frames_gray = [], []
    i, success = 1, True

    while success and len(frames_bgr) < max_frames:
        success, frame = cap.read()
        if not success or frame is None:
            break
        if i % step == 0:
            small = resize_nn(frame, scale)
            gray  = lb1.rgb_to_gray(small[..., ::-1])   # BGR→RGB→gray
            frames_bgr.append(small)
            frames_gray.append(gray)
        i += 1

    cap.release()
    return frames_bgr, frames_gray, fps / step, frames_bgr[0].shape[:2][::-1]

Параметры запуска:

Параметр Значение Описание
MAX_FRAMES 5000 Лимит кадров
FRAME_STEP 1 Каждый кадр
SCALE 0.5 Уменьшение в 2×

Результат (3_1_initial_frames.png):

alt text

Левый кадр — начало видео (фон без мышки). Правый — кадр с движением.


3.2 Алгоритм вычитания фона

Метод 1 — Статическое усреднение

def sr_mod_fon(arr_gray):
    """
    Статический фон: B[i,j] = среднее по всем кадрам.
    Векторизовано через numpy.
    """
    acc = np.zeros(arr_gray[0].shape, dtype=np.float64)
    for img in arr_gray:
        acc += img.astype(np.float64)
    return np.uint8(acc / len(arr_gray))

Метод 2 — Обновляемый фон (скользящее среднее)

def mod_fon_moments(arr_gray, bg_thresh=20, jump_thresh=30):
    """
    Адаптивный фон: bg = 0.5·frame + 0.5·bg
    Дополнительно: вычисляет центроид через моменты для каждого кадра.
    """
    h, w = arr_gray[0].shape
    bg   = np.zeros((h, w), dtype=np.uint8)
    mom  = [None] * len(arr_gray)

    for k, frame in enumerate(arr_gray):
        bg = np.uint8(0.5 * frame.astype(np.float32)
                    + 0.5 * bg.astype(np.float32))

        diff   = np.abs(frame.astype(np.int16) - bg.astype(np.int16))
        diff_f = lb2.gaussian_blur_manual(diff.astype(np.float64), sigma=1.0)
        thresh = lb1.threshold_gray(
            np.clip(diff_f, 0, 255).astype(np.uint8), thresh=bg_thresh)

        m00 = float(thresh.sum()) / 255.0
        if m00 > 1e-5:
            ys, xs = np.where(thresh > 0)
            cx = float((xs * thresh[ys, xs]).sum()) / 255.0 / (m00 + 1e-5)
            cy = float((ys * thresh[ys, xs]).sum()) / 255.0 / (m00 + 1e-5)
        else:
            cx, cy = 0.0, 0.0

        # Фильтрация выбросов (сглаживание резких скачков)
        if k > 0 and mom[k-1] is not None:
            if abs(cx - mom[k-1][0]) > jump_thresh or \
               abs(cy - mom[k-1][1]) > jump_thresh:
                mom[k] = mom[k-1]
            else:
                mom[k] = (cx, cy)
        else:
            mom[k] = (cx, cy)

    return bg, mom

Результат (3_2_background_avg_vis.png):

alt text

Усреднённый фон: чистая арена. Слабые контуры — места, где мышка подолгу сидела неподвижно.

Результат (3_2_background_upd_vis.png):

alt text

Адаптивный фон: финальное состояние после прохода по всем кадрам.


3.3–3.5 Маска переднего плана

def subtract_and_clean(frame_gray, bg_gray, thresh=20):
    """
    3.3  diff  = |frame − bg|          — вычитание фона
    3.4  mask  = threshold(diff)        — бинарная маска
    3.5  очистка: gaussian blur (lb2) + порог (lb1)
    """
    diff   = np.abs(frame_gray.astype(np.int16)
                  - bg_gray.astype(np.int16))
    diff_f = lb2.gaussian_blur_manual(diff.astype(np.float64), sigma=1.0)
    mask   = lb1.threshold_gray(
        np.clip(diff_f, 0, 255).astype(np.uint8), thresh=thresh)
    return mask

Порог BG_THRESH = 20 — пиксели с отклонением менее 20 считаются фоном.
Гауссово размытие (σ=1) перед порогом убирает высокочастотный шум.


3.6 Связные компоненты

Реализован алгоритм двухпроходной маркировки (Two-Pass Connected Components) с применением структуры Union-Find (СНМ):

def connected_components(binary_mask):
    h, w   = binary_mask.shape
    labels = np.zeros((h, w), dtype=np.int32)
    parent = [0]; nl = 1

    def find(x):
        while parent[x] != x:
            parent[x] = parent[parent[x]]   # path compression
            x = parent[x]
        return x

    def union(a, b):
        ra, rb = find(a), find(b)
        if ra != rb:
            parent[rb] = ra

    parent.append(1)
    for y in range(h):
        for x in range(w):
            if binary_mask[y, x] == 0:
                continue
            nb = []
            if y > 0 and labels[y-1, x] > 0: nb.append(labels[y-1, x])
            if x > 0 and labels[y, x-1] > 0: nb.append(labels[y, x-1])
            if not nb:
                nl += 1; parent.append(nl); labels[y, x] = nl
            else:
                m = min(nb); labels[y, x] = m
                for n in nb:
                    if n != m: union(m, n)

    # второй проход — финальное переименование меток
    for y in range(h):
        for x in range(w):
            if labels[y, x] > 0:
                labels[y, x] = find(labels[y, x])

    uniq = np.unique(labels); uniq = uniq[uniq > 0]
    remap = {old: i+1 for i, old in enumerate(uniq)}
    for old, new in remap.items():
        labels[labels == old] = new

    return labels, len(uniq)

3.7 Фильтрация объектов

def filter_objects(label_map, n_labels, min_area=150, max_ratio=0.5):
    """
    Убираем:
    - слишком мелкие компоненты (< MIN_AREA=150 пкс) — шум
    - слишком крупные (> 50% кадра)        — артефакты
    """
    h, w     = label_map.shape
    max_area = int(h * w * max_ratio)
    areas    = np.bincount(label_map.ravel(), minlength=n_labels + 1)
    objects  = []
    for lbl in range(1, n_labels + 1):
        a = int(areas[lbl])
        if min_area <= a <= max_area:
            ys, xs = np.where(label_map == lbl)
            objects.append({"label": lbl, "area": a,
                             "x0": int(xs.min()), "y0": int(ys.min()),
                             "x1": int(xs.max()), "y1": int(ys.max())})
    return objects

3.8 Центроид через моменты

def compute_centroid_moments(label_map, lbl):
    """
    cx = m10/m00 = (Σ x) / N
    cy = m01/m00 = (Σ y) / N
    Для бинарной маски все веса равны 1.
    """
    ys, xs = np.where(label_map == lbl)
    if len(xs) == 0:
        return None
    m00 = float(len(xs))
    return (float(xs.sum()) / m00, float(ys.sum()) / m00)

3.9 Построение траектории

Накопленный список traj содержит координаты (cx, cy) для каждого кадра, где мышка была обнаружена. На основании этих данных строятся три графика:

def plot_trajectory(traj, video_size, fps, out_dir, prefix="3_9"):
    arr = np.array(traj)
    xs, ys = arr[:, 0], arr[:, 1]
    t   = np.arange(len(xs)) / fps
    spd = np.sqrt(np.diff(xs)**2 + np.diff(ys)**2) * fps
    # ...

2D траектория (avg — статический фон):

alt text

2D траектория (upd — обновляемый фон):

alt text Координаты X(t), Y(t):

alt text

Скорость движения:

alt text


3.10 Визуализация результатов

Для 5 равномерно распределённых кадров сохраняются:

  • original — исходный кадр
  • mask — маска переднего плана
  • result — наложение bounding box + центроид + траектория
# Рисование bounding box без cv2.rectangle
def draw_rect(img, x0, y0, x1, y1, color=(0, 255, 0), t=2):
    h, w = img.shape[:2]
    for d in range(t):
        if y0-d >= 0: img[y0-d, x0:x1+1] = color
        if y1+d <  h: img[y1+d, x0:x1+1] = color
        if x0-d >= 0: img[y0:y1+1, x0-d] = color
        if x1+d <  w: img[y0:y1+1, x1+d] = color

# Рисование точки центроида
def draw_circle(img, cx, cy, r=5, color=(0, 0, 255)):
    h, w   = img.shape[:2]
    ys, xs = np.ogrid[:h, :w]
    img[(xs-int(cx))**2 + (ys-int(cy))**2 <= r**2] = color

# Рисование линии траектории (алгоритм Брезенхэма)
def draw_line(img, pt1, pt2, color=(255, 180, 0)):
    x0, y0 = int(pt1[0]), int(pt1[1])
    x1, y1 = int(pt2[0]), int(pt2[1])
    # ... алгоритм Брезенхэма

Строка результатов alt text alt text alt text alt text alt text

alt text

Строка масок: alt text alt text alt text alt text alt text

alt text Строка оригиналов:

alt text alt text alt text alt text alt text

alt text


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

Все графические примитивы реализованы вручную (без cv2.line, cv2.rectangle, cv2.circle):

Функция Метод Описание
draw_rect Покрасить строки/столбцы по границам Зелёный bounding box
draw_circle Булева маска по уравнению окружности Красный центроид
draw_line Алгоритм Брезенхэма Цветная траектория
Цвет линии траектории меняется от синего к красному пропорционально времени:
for i in range(1, len(traj)):
    a     = i / max(len(traj), 1)
    color = (int(255*(1-a)), 50, int(255*a))   # BGR
    draw_line(vis, traj[i-1], traj[i], color=color)

5. Результаты

Программа была запущена на видео mouse_1.avi (~6076 кадров, масштаб 0.5×).

Показатель Значение
Обработано кадров 5000
Точек траектории ~4800+
Диапазон X зависит от видео
Диапазон Y зависит от видео
Сохранено файлов 28 PNG

Сохранённые артефакты

lab3_output/
  3_1_initial_frames.png        — начальный кадр и кадр с движением
  3_2_background_avg_vis.png    — модель фона (усреднение)
  3_2_background_upd_vis.png    — модель фона (обновляемый)
  3_9_avg_traj_2d.png           — 2D траектория (статический фон)
  3_9_avg_traj_coords.png       — X(t), Y(t) координаты
  3_9_avg_traj_speed.png        — скорость движения
  3_9_upd_traj_2d.png           — 2D траектория (адаптивный фон)
  3_9_upd_traj_coords.png       — X(t), Y(t) координаты
  3_9_upd_traj_speed.png        — скорость движения
  3_10_frame0X_original.png     — 5 исходных кадров
  3_10_frame0X_mask.png         — 5 масок переднего плана
  3_10_frame0X_result.png       — 5 результирующих кадров
  3_10_row_original.png         — сводная строка оригиналов
  3_10_row_mask.png             — сводная строка масок
  3_10_row_result.png           — сводная строка результатов

6. Анализ ошибок и ограничений

Известные ограничения

  1. Производительность connected_components
    Двойной Python-цикл по пикселям работает медленно. При SCALE=0.5 (кадр ~240×180) один вызов занимает несколько секунд. Для реального времени следует использовать cv2.connectedComponents или переписать через scipy.ndimage.label.

  2. Коэффициент смешения в mod_fon
    Значение α=0.5 в формуле bg = 0.5·frame + 0.5·bg является очень агрессивным — фон адаптируется за ~2–3 кадра. Рекомендуемые значения: α ∈ [0.02, 0.1] для более стабильного фона.

  3. Выбор «главного» объекта
    Отслеживается только самая крупная компонента (max(objs, key=lambda o: o["area"])). При наличии нескольких одинаково крупных объектов или слиянии теней алгоритм может потерять мышку.

  4. Опечатка в конце скрипта
    В строке print_summary(traj, fps). присутствует лишняя точка (.), которая вызовет SyntaxError при запуске. Необходимо исправить на:

    print_summary(traj, fps)

7. Выводы

В ходе работы реализован полный конвейер анализа видеопотока:

  1. Чтение видео — покадровое считывание с масштабированием методом ближайшего соседа
  2. Два метода вычитания фона — статическое усреднение даёт более чистый фон, адаптивный лучше работает при изменении освещения
  3. Сегментация объекта — связные компоненты + фильтрация по площади позволяет надёжно выделить мышку
  4. Вычисление центроида — через моменты изображения (m10/m00, m01/m00)
  5. Траектория — накопленный путь движения мышки визуализирован в 2D и в виде временны́х графиков X(t), Y(t) и скорости v(t)

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


Лабораторная работа 4: Классификация изображений CIFAR-10 с помощью CNN

Цель работы: Реализовать сверточную нейронную сеть (CNN) для классификации изображений датасета CIFAR-10, изучить архитектуру глубоких нейронных сетей и методы борьбы с переобучением.

Dota 2 Hero Recognition — Лабораторная работа v5

Задача: Многоклассовая классификация героев Dota 2 по изображению с применением Transfer Learning на базе EfficientNetB0.


Содержание

  1. Обзор проекта
  2. Структура файлов
  3. Набор данных
  4. Предобработка и аугментация
  5. Архитектура нейронной сети
  6. Функция потерь — Focal Loss
  7. Расписание скорости обучения — Warmup Cosine Decay
  8. Стратегия обучения — двухфазный Transfer Learning
  9. Callbacks и регуляризация
  10. Оценка модели — TTA и метрики
  11. Результаты
  12. Требования и запуск

Обзор проекта

Лабораторная работа реализует систему распознавания 124 героев Dota 2 по изображению.
Используется предобученная сверточная нейронная сеть EfficientNetB0 (backbone), поверх которой дообучается классификационная "голова". Применяется двухфазная схема обучения: сначала обучаются только новые слои при заморозке backbone, затем выполняется полная тонкая настройка (fine-tuning) с разморозкой backbone.

Ключевые технические решения:

  • Transfer Learning с EfficientNetB0 (предобучена на ImageNet)
  • Синтетическая и онлайн-аугментация данных
  • Focal Loss с Label Smoothing против дисбаланса классов
  • Warmup + Cosine Decay расписание скорости обучения
  • Test Time Augmentation (TTA) для улучшения качества предсказаний


Набор данных

Состав датасета

Сплит Изображений Доля
Train 7 048 ~70%
Val 1 360 ~15%
Test 1 364 ~15%
Всего 9 772 100%
  • Классов: 124 героя Dota 2
  • Формат: PNG, входной размер изображения — 224×224 пикселей (3 канала RGB)

Разбивка createSplits

def create_splits(heroes, src, dst, train_r=0.70, val_r=0.15, seed=42):
    ...
    originals  = [f for f in imgs if "syn" not in f.name]
    synthetics = [f for f in imgs if "syn"     in f.name]

    i1 = int(n * train_r)
    i2 = int(n * (train_r + val_r))

    parts = {
        "train": originals + synthetics[:i1],
        "val":   synthetics[i1:i2],
        "test":  synthetics[i2:],
    }

Логика:

  • Все оригинальные изображения идут в train — это гарантирует, что реальные данные используются только для обучения.
  • Синтетические (аугментированные заранее) изображения делятся между train, val и test в соотношении 70/15/15.
  • Такой подход предотвращает data leakage: val/test содержат только синтетику, а не оригиналы.

Предобработка и аугментация

1. Предобработка EfficientNet

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

def eff_prep(x):
    return tf.keras.applications.efficientnet.preprocess_input(x)

Функция масштабирует значения пикселей из [0, 255] в диапазон [-1, 1], как требуется EfficientNet.

2. Онлайн-аугментация (ImageDataGenerator)

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

train_gen = ImageDataGenerator(
    preprocessing_function = eff_prep,
    rotation_range         = 20,        # поворот ±20°
    width_shift_range      = 0.15,      # горизонтальный сдвиг ±15%
    height_shift_range     = 0.15,      # вертикальный сдвиг ±15%
    zoom_range             = (0.85, 1.15),  # масштабирование 85–115%
    horizontal_flip        = True,      # зеркальное отражение
    brightness_range       = (0.75, 1.25),  # яркость 75–125%
    fill_mode              = "reflect", # заполнение границ отражением
)

alt text alt text alt text Онлайн-аугментация случайным образом искажает изображение при каждой итерации, тем самым искусственно увеличивая эффективный объём обучающей выборки и снижая переобучение.

Val и Test-генераторы используют только preprocessing_function=eff_prep без аугментации.


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

Модель называется dota2v5 и строится поверх EfficientNetB0.

Схема модели

Input (224, 224, 3)
        │
EfficientNetB0 (backbone)
  └─ выход: (None, 7, 7, 1280)   [4 049 571 параметров; заморожен на фазе 1]
        │
GlobalAveragePooling2D
  └─ выход: (None, 1280)          [0 параметров]
        │
Dense(768, activation="relu")
  └─ L2 regularization (λ=5e-5)  [983 808 параметров]
        │
BatchNormalization                 [3 072 параметра]
        │
Dropout(0.40)
        │
Dense(384, activation="relu")
  └─ L2 regularization (λ=5e-5)  [295 296 параметров]
        │
BatchNormalization                 [1 536 параметров]
        │
Dropout(0.30)
        │
Dense(124, activation="softmax")  [47 740 параметров]  ← выходной слой (124 класса)

Параметры модели

Тип Количество
Всего параметров 5 381 023
Обучаемых (фаза 1) 1 329 148 (24.7%)
Необучаемых (фаза 1) 4 051 875 (backbone заморожен)
Обучаемых (фаза 2) ~5 330 668 (backbone разморожен частично)

Пояснение ключевых слоёв

EfficientNetB0 (backbone)
EfficientNet — семейство CNN, которое совместно масштабирует глубину, ширину и разрешение сети с помощью compound scaling. B0 — базовая версия с оптимальным балансом точности и скорости. Предобученная на ImageNet, она извлекает универсальные визуальные признаки.

GlobalAveragePooling2D
Заменяет Flatten: усредняет каждый из 1280 каналов пространственно (7×7 → 1 число). Снижает размерность с 7×7×1280 до 1280, уменьшает риск переобучения по сравнению с полным разворачиванием.

Dense + BatchNormalization + Dropout
Классификационная голова из двух полносвязных блоков:

  • Dense — линейное преобразование + нелинейная активация ReLU
  • BatchNormalization — нормализует активации по мини-батчу, ускоряет обучение и стабилизирует градиенты
  • Dropout — случайно обнуляет нейроны с заданной вероятностью (0.40 и 0.30), предотвращая переобучение

Выходной слой Dense(124, softmax)
Преобразует вектор признаков в вектор вероятностей длиной 124 (по числу героев). softmax гарантирует, что сумма вероятностей равна 1.


Функция потерь — Focal Loss

Проблема и мотивация

При многоклассовой классификации стандартная Cross-Entropy Loss присваивает одинаковый вес "лёгким" (уверенно классифицируемым) и "трудным" примерам. Focal Loss решает эту проблему, снижая вес лёгких примеров.

Формула

$$FL(p_t) = -(1 - p_t)^{\gamma} \cdot \log(p_t)$$

где:

  • $p_t$ — предсказанная вероятность для правильного класса
  • $\gamma$ (gamma) — фокусирующий параметр (в работе: $\gamma = 2.0$)
  • $(1 - p_t)^{\gamma}$ — модулирующий множитель: если пример классифицирован уверенно ($p_t \to 1$), множитель $\to 0$ и потеря минимальна

Label Smoothing

К Focal Loss добавлено сглаживание меток (label_smoothing=0.1):

$$y_{smooth} = y \cdot (1 - \varepsilon) + \frac{\varepsilon}{N_{cls}}$$

Вместо "жёстких" меток 0/1 используются "мягкие" 0.1/0.9. Это снижает уверенность модели в предсказаниях, уменьшает переобучение и улучшает калибровку.

Реализация

class FocalLoss(tf.keras.losses.Loss):
    def __init__(self, gamma=2.0, label_smoothing=0.1):
        self.gamma = gamma
        self.label_smoothing = label_smoothing

    def call(self, y_true, y_pred):
        n_cls = tf.cast(tf.shape(y_true)[-1], tf.float32)
        # Label smoothing
        y_true = y_true * (1.0 - self.label_smoothing) + self.label_smoothing / n_cls
        # Clip predictions
        y_pred = tf.clip_by_value(y_pred, 1e-7, 1.0 - 1e-7)
        # Cross-entropy
        ce = -tf.reduce_sum(y_true * tf.math.log(y_pred), axis=-1)
        # Focal weight
        pt = tf.reduce_sum(y_true * y_pred, axis=-1)
        w  = tf.pow(1.0 - pt, self.gamma)
        return tf.reduce_mean(w * ce)

Расписание скорости обучения — Warmup Cosine Decay

Зачем нужен Warmup

При старте обучения веса backbone случайны (или требуют адаптации). Слишком большой LR в начале дестабилизирует обучение. Linear Warmup постепенно увеличивает LR от 0 до base_lr за первые warmup_steps.

Cosine Decay

После прогрева LR убывает по косинусу:

$$LR(t) = LR_{min} + \frac{1}{2}(LR_{base} - LR_{min}) \cdot \left(1 + \cos\left(\pi \cdot \frac{t - t_{warmup}}{T - t_{warmup}}\right)\right)$$

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

Параметры фазы 2

Параметр Значение
base_lr 2e-4
min_lr 1e-7
total_steps 220 × 60
warmup_epochs 5

Реализация

class WarmupCosineDecay(tf.keras.optimizers.schedules.LearningRateSchedule):
    def call(self, step):
        step = tf.cast(step, tf.float32)
        # Linear warmup
        warmup_lr = self.base_lr * step / self.warmup_steps
        # Cosine decay
        progress = (step - self.warmup_steps) / (self.total_steps - self.warmup_steps)
        progress = tf.clip_by_value(progress, 0.0, 1.0)
        cosine_lr = self.min_lr + 0.5 * (self.base_lr - self.min_lr) * (1.0 + tf.math.cos(np.pi * progress))
        return tf.where(step < self.warmup_steps, warmup_lr, cosine_lr)

Стратегия обучения — двухфазный Transfer Learning

Фаза 1: Обучение головы (Head Training)

Цель: Обучить классификационную голову при замороженном backbone.

Параметр Значение
Backbone Заморожен (trainable=False)
Обучаемых параметров 1 329 148 (24.7%)
Оптимизатор Adam(lr=0.001)
Эпох 20
Steps/epoch 220
EarlyStopping patience=7, monitor=val_accuracy
Результат Val Acc: 87.13%, Val Top-3: 93.31%

На этом этапе backbone используется как фиксированный feature extractor. Обучение быстрое и стабильное, поскольку предобученные веса не изменяются.

Фаза 2: Fine-Tuning (Тонкая настройка)

Цель: Дообучить backbone вместе с головой на меньшем LR с cosine decay.

backbone.trainable = True
for layer in backbone.layers[:UNFREEZE_AT]:
    layer.trainable = False  # первые N слоёв остаются замороженными
Параметр Значение
Backbone Частично разморожен
Обучаемых параметров ~5 330 668
LR Schedule WarmupCosineDecay(base_lr=2e-4, min_lr=1e-7)
Warmup 5 эпох
Эпох (макс.) 60
EarlyStopping patience=12, monitor=val_accuracy
Результат Val Acc: 99.78%, Val Top-3: 99.93%

Fine-tuning позволяет backbone адаптироваться к специфике домена Dota 2, значительно превышая результат фазы 1.

Почему такая стратегия?

  1. Предотвращение разрушения признаков: Если сразу обучать весь backbone на высоком LR, предобученные веса ImageNet "забудутся" слишком быстро.
  2. Эффективность: Фаза 1 быстро достигает разумного стартового состояния для головы.
  3. Стабильность: Малый LR в фазе 2 + cosine decay обеспечивают точную настройку без дестабилизации.

Callbacks и регуляризация

EarlyStopping

EarlyStopping(
    monitor="val_accuracy",
    patience=7,            # фаза 1; patience=12 в фазе 2
    restore_best_weights=True
)

Прекращает обучение, если val_accuracy не улучшается patience эпох подряд. restore_best_weights=True гарантирует возврат к весам наилучшей эпохи.

ModelCheckpoint

ModelCheckpoint(
    "best_phase1.keras",   # "best_final.keras" в фазе 2
    monitor="val_accuracy",
    save_best_only=True
)

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

L2 Regularization

В Dense-слоях применяется L2-регуляризация:

kernel_regularizer=l2(5e-5)

Штрафует большие веса, добавляя λ * sum(w²) к функции потерь. Снижает переобучение.

BatchNormalization

Нормализует активации по мини-батчу: $$\hat{x} = \frac{x - \mu_B}{\sqrt{\sigma_B^2 + \varepsilon}}$$

Ускоряет обучение, допускает более высокий LR, действует как лёгкий регуляризатор.

Dropout

Случайно обнуляет нейроны с вероятностью p во время обучения:

  • После Dense(768): p = 0.40
  • После Dense(384): p = 0.30

Во время инференса dropout отключается; активации масштабируются на (1 - p).


Оценка модели — TTA и метрики

Test Time Augmentation (TTA)

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

$$P_{TTA}(y|x) = \frac{1}{N} \sum_{t=1}^{N} P(y | \text{aug}_t(x))$$

В работе используется N = 10 аугментаций:

tta_aug = tf.keras.Sequential([
    layers.RandomFlip("horizontal"),
    layers.RandomRotation(0.08),
    layers.RandomZoom(-0.10, 0.10),
    layers.RandomBrightness(0.12),
    layers.RandomContrast(0.12),
])

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

Метрики

Метрика Описание
Top-1 Accuracy Доля примеров, где правильный класс — первый в предсказании
Top-3 Accuracy Доля примеров, где правильный класс входит в топ-3 предсказания
Macro F1-Score Среднее F1 по всем классам (равный вес каждого)
Weighted F1-Score Среднее F1, взвешенное по числу примеров в классе

F1-Score для класса c:

$$F1_c = \frac{2 \cdot Precision_c \cdot Recall_c}{Precision_c + Recall_c}$$

Per-hero F1 позволяет выявить героев, которых модель распознаёт плохо (confusion matrix и нижний/верхний рейтинг).

Confusion Matrix

Матрица ошибок 124×124 показывает, какие герои чаще всего путаются между собой. Визуализируется как тепловая карта.


Результаты

графики обучения + LR schedule

alt text

Матрица ошибок

alt text

Истинные герои:

alt text alt text alt text alt text alt text

Метрика Без TTA С TTA (N=10)
Top-1 Accuracy ~99.X% ~99.X%
Top-3 Accuracy ~99.9% ~99.9%
Macro F1 ~0.99 ~0.99

Итоговые числа из выводов :
Val Acc (фаза 2): 99.78% | Val Top-3: 99.93%

Примеры лучших/худших героев по F1

Топ-10 (F1 ≈ 1.000): Broodmother, Crystal Maiden, Axe, ...
Нижние-10 (F1 ~0.909–0.952): Grimstroke, Sniper, Shadow Demon, Medusa, Weaver, Lycan, Luna, ...

Эти герои визуально похожи или имеют редкие изображения в датасете.


Теоретическое резюме методов

Метод Зачем применяется
Transfer Learning (EfficientNetB0) Использование признаков ImageNet; экономия данных и времени обучения
Two-phase training Стабильное обучение: сначала голова, потом fine-tuning backbone
Focal Loss Фокус на трудных примерах; борьба с дисбалансом классов
Label Smoothing Снижение уверенности модели; регуляризация
Warmup + Cosine Decay LR Стабильный старт + плавное снижение LR в конце
BatchNormalization Ускорение обучения, стабильность градиентов
Dropout Регуляризация, снижение переобучения
L2 Regularization Штраф за большие веса
Online Augmentation Искусственное расширение датасета в процессе обучения
Offline (Synthetic) Augmentation Предварительно созданные дополнительные данные
GlobalAveragePooling2D Снижение размерности без потери пространственной информации
TTA (Test Time Augmentation) Ансамблирование предсказаний для повышения точности
EarlyStopping Прекращение при отсутствии прогресса; возврат лучших весов
ModelCheckpoint Сохранение модели при улучшении val_accuracy
Top-K Accuracy Оценка при неоднозначных предсказаниях
Macro/Weighted F1 Оценка качества на несбалансированных классах

About

No description, website, or topics provided.

Resources

Stars

1 star

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages