Для 1 лаб работы по CV необходимо реализовать базовый минимум операций над изображениями Входное изображение в формате (RGB, не чёрно-белое)
- Фильтры 1.1 Медианный фильтр 1.2 Фильтр гаусса
- Морфологические операции 2.1 Эрозия 2.2 Дилатация
- Прочие операции 3.1 пороговая бинаризация (для rgb и grayscale изображения) 3.2 выравнивание гистограммы 3.3 поворот изображений на угол кратный 90 градусов
Использовать методы OpenCV для реализации операций нельзя. Допустимы только методы cv2.imread() и cv2.imshow(). Все методы должны быть реализованы вручную.
Назначение: подавление импульсных шумов («соль и перец») при сохранении границ.
Алгоритм:
- Применить
pad_reflectс отступомksize // 2. - Для каждого пикселя
(i, j)извлечь окноksize × ksize. - Заменить значение пикселя медианой элементов окна.
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.
Входное изображение:
После медианного фильтра (RGB, ksize=7):
После медианного фильтра (grayscale, ksize=3):
Назначение: размытие / шумоподавление с плавным взвешиванием соседей.
Ядро Гаусса размером k×k с параметром σ:
Ядро нормируется так, чтобы сумма всех элементов равнялась 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):
После гауссового фильтра (grayscale):
Назначение: сужение светлых областей, удаление мелких деталей и выступов.
Для каждого пикселя берётся минимум значений в окне, заданном структурным элементом 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):
После эрозии (RGB):
Назначение: расширение светлых областей, «закрытие» мелких тёмных дыр.
Симметрично эрозии, но берётся максимум:
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):
После дилатации (RGB):
Назначение: сегментация — разделение пикселей на «тёмные» и «светлые».
Перевод в grayscale по стандартной формуле яркости:
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):
RGB порог по каналам (thresh=128):
RGB порог по яркости (thresh=128):
Назначение: повышение контрастности изображения за счёт равномерного перераспределения яркостей.
Для grayscale используется CDF гистограммы:
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):
Выравнивание гистограммы (RGB через YCbCr):
1.яркость (Y) RGB до эквализации

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

Назначение: геометрические преобразования без интерполяции.
Реализованы через транспонирование и зеркальное отражение массива — операции без потерь качества:
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°:
Поворот на 180°:
Поворот на 270°:
| Метод | Тип операции | Влияет на цвет | Обратима |
|---|---|---|---|
| Медианный фильтр | Нелинейная фильтрация | Нет (по каналам) | Нет |
| Гауссов фильтр | Линейная свёртка | Нет (по каналам) | Нет |
| Эрозия | Морфология (min) | Нет (по каналам) | Нет |
| Дилатация | Морфология (max) | Нет (по каналам) | Нет |
| Эрозия + Дилатация | Opening/Closing | — | Нет |
| Порог (grayscale) | Сегментация | — | Нет |
| Порог per_channel | Сегментация | Да | Нет |
| Порог luminance | Сегментация | Нет | Нет |
| Эквализация (gray) | Нормировка яркости | — | Нет |
| Эквализация (RGB) | Нормировка яркости YCbCr | Минимально | Нет |
| Поворот ×90° | Геометрия | Нет | Да (k=4 ≡ 0) |
Лабораторная работа №2. Визуальная одометрия (навигация) Цель: Разработать систему визуальной одометрии (навигации) по группе фотографий. Ход работы: сделайте не менее 8 фото с переносом камеры или ноутбука по квадрату (то есть двиньте сначала вправо, потом вперед, потом влево, потом назад и обратно в начальную точку). Используя данные фотографии реализуйте следующее:
-
Определите на каждой фотографии ключевые точки
-
Отфильтруйте самые наилучшие применяю адаптивный радиус и локальные максимумы, не забудьте так же выровнять по яркости изображения.
-
Постройте по каждой точке дескриптор (можете использовать любой, рекомендуется SIFT)
-
Сопоставьте два соседних изображения на предмет соответствия ключевых точек. То есть определите пары одинаковых точек.
-
Постройте модель преобразования изображений, учитывайте только поворот и сдвиг.
-
С учетом полученных моделей постройте траекторию движения камеры.
Проверка работоспособности: будет осуществляться на специальной группе фото, предоставленных преподавателем. Траектория движения, для которых недоступна.
В процессе выполнения вы можете использовать готовые функции по погрузке данных, перевода в цветовые пространства, фильтрации, для построения прямых и траекторий. Функции 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 | ![]() |
Начальная точка |
| 2 | ![]() |
Сдвиг вправо |
| 3 | ![]() |
Сдвиг вправо + вперёд |
| 4 | ![]() |
Сдвиг вперёд |
| 5 | ![]() |
Сдвиг влево |
| 6 | ![]() |
Сдвиг влево + назад |
| 7 | ![]() |
Сдвиг назад |
| 8 | ![]() |
Возврат к началу |
Ориентир на изображениях — два магнита на холодильнике
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
OpenCV читает изображения в порядке каналов BGR. Переворачиваем каналы вручную:
def bgr_to_rgb(img):
return img[..., ::-1].copy()Стандартная яркостная формула взвешивает синий, зелёный и красный каналы в соответствии с чувствительностью человеческого зрения:
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)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 пкс | Размер тайла |
Сравнение предобработки
Рис. 3 — Кадр 1: оригинал → grayscale → после CLAHE.
Гистограммы яркости
Рис. 4 — Гистограммы яркости кадра 1 до и после CLAHE: виден «расправленный» распределение.
Все кадры CLAHE
Рис. 5 — Все 8 кадров после применения CLAHE.
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)]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 раза (даунсэмплинг) — для захвата крупных структур, что даёт двухмасштабное покрытие сцены.
Для ограничения числа точек до 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]Алгоритм гарантирует равномерное пространственное распределение точек: сильные точки подавляют слабых соседей.
Рис. 6 — Ключевые точки (DoG + Adaptive NMS, до 500 на кадр) на всех 8 кадрах.
SIFT-дескриптор описывает локальную текстуру вокруг ключевой точки как набор взвешенных гистограмм ориентаций градиента:
- Градиент — центральные разности 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)-
Доминирующая ориентация (36-бинная гистограмма, взвешенная по |∇|) → инвариантность к повороту.
-
16×16 патч делится на 4×4 ячейки, в каждой — 8-бинная гистограмма → вектор 4×4×8 = 128 измерений.
-
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)Рис. 7 — 128-мерный SIFT-дескриптор ключевой точки кадра 1: тепловая карта (слева) и вектор (справа). Красная пунктирная линия — порог клиппирования 0.2.
Для каждого дескриптора из кадра 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
На каждом рисунке: слева — кадр i, справа — кадр i+1. Цветные эллипсы — ключевые точки (детектированные DoG-блобы), цветные линии — принятые совпадения (Lowe ratio ≤ 0.80). Для наглядности отображается до 40 совпадений из всех найденных.
Сетка совпадений
Рис. 8 — Совпадения ключевых точек для всех 7 пар соседних кадров.
Число совпадений
Рис. 9 — Количество совпадений в каждой паре кадров. Красная пунктирная линия — минимальный порог для RANSAC (4 пары).
Модель «вращение + перенос» (4 степени свободы: θ, tx, ty с ограничением изотропного масштаба):
[x'] [cos θ -sin θ | tx] [x]
[y'] = [sin θ cos θ | ty] [y]
[1]
Из двух пар точек аналитически вычисляем cos(θ), sin(θ) и t:
cos(θ) = (Δp1 · Δp2) / |Δp1|²
sin(θ) = (Δp1 × Δp2) / |Δp1|²
t = p2₀ − R · p1₀
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 (метод Прокруста) |
Трансформация 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])))Траектория и смещения
Рис. 10 — Вид сверху на траекторию камеры (слева, цвет кодирует время), вектор смещений ΔX/ΔY в пикселях (справа вверху) и угол вращения (справа внизу).
| Этап | Параметры | Результат |
|---|---|---|
| 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, визуальная одометрия
Цель: Научиться анализировать видеопоток.
Ход работы: получить видеопоток с Web-камеры и определить перемещающийся в кадре объект. Используя данные видеопотока реализуйте следующее:
-
Реализуйте получение данных с Web-камеры
-
Реализуйте алгоритм вычитания фона
-
Реализуйте определение движущегося предмета
-
Постройте траекторию движения объекта.
-
Проведите тестирование на тестовом видео. (возьмите видео у преподавателя.)
Проверка работоспособности: будет осуществляться на специальном видео, предоставленном преподавателем. Траектория движения, для которых недоступна.
Дисциплина: Компьютерное зрение
Цель: Научиться анализировать видеопоток, реализовать алгоритм вычитания фона и построить траекторию движущегося объекта.
- Теоретическая часть
- Структура проекта
- Реализация
- 3.1 Получение видеопотока
- 3.2 Алгоритм вычитания фона
- 3.3–3.5 Маска переднего плана
- 3.6 Связные компоненты
- 3.7 Фильтрация объектов
- 3.8 Центроид через моменты
- 3.9 Построение траектории
- 3.10 Визуализация результатов
- Вспомогательные функции рисования
- Результаты
- Анализ ошибок и ограничений
- Выводы
Основная идея: если есть модель «пустого» фона 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₀₀
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
Видеофайл читается покадрово через 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):
Левый кадр — начало видео (фон без мышки). Правый — кадр с движением.
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))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):
Усреднённый фон: чистая арена. Слабые контуры — места, где мышка подолгу сидела неподвижно.
Результат (3_2_background_upd_vis.png):
Адаптивный фон: финальное состояние после прохода по всем кадрам.
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) перед порогом убирает высокочастотный шум.
Реализован алгоритм двухпроходной маркировки (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)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 objectsdef 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)Накопленный список 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 — статический фон):
2D траектория (upd — обновляемый фон):
Скорость движения:
Для 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])
# ... алгоритм БрезенхэмаВсе графические примитивы реализованы вручную (без 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)Программа была запущена на видео 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 — сводная строка результатов
-
Производительность
connected_components
Двойной Python-цикл по пикселям работает медленно. ПриSCALE=0.5(кадр ~240×180) один вызов занимает несколько секунд. Для реального времени следует использоватьcv2.connectedComponentsили переписать черезscipy.ndimage.label. -
Коэффициент смешения в
mod_fon
Значение α=0.5 в формулеbg = 0.5·frame + 0.5·bgявляется очень агрессивным — фон адаптируется за ~2–3 кадра. Рекомендуемые значения: α ∈ [0.02, 0.1] для более стабильного фона. -
Выбор «главного» объекта
Отслеживается только самая крупная компонента (max(objs, key=lambda o: o["area"])). При наличии нескольких одинаково крупных объектов или слиянии теней алгоритм может потерять мышку. -
Опечатка в конце скрипта
В строкеprint_summary(traj, fps).присутствует лишняя точка (.), которая вызоветSyntaxErrorпри запуске. Необходимо исправить на:print_summary(traj, fps)
В ходе работы реализован полный конвейер анализа видеопотока:
- Чтение видео — покадровое считывание с масштабированием методом ближайшего соседа
- Два метода вычитания фона — статическое усреднение даёт более чистый фон, адаптивный лучше работает при изменении освещения
- Сегментация объекта — связные компоненты + фильтрация по площади позволяет надёжно выделить мышку
- Вычисление центроида — через моменты изображения (m10/m00, m01/m00)
- Траектория — накопленный путь движения мышки визуализирован в 2D и в виде временны́х графиков X(t), Y(t) и скорости v(t)
Оба метода вычитания фона успешно детектируют мышку. Статический фон даёт более стабильную траекторию на всём видео, адаптивный лучше справляется с медленными изменениями освещения.
Цель работы: Реализовать сверточную нейронную сеть (CNN) для классификации изображений датасета CIFAR-10, изучить архитектуру глубоких нейронных сетей и методы борьбы с переобучением.
Задача: Многоклассовая классификация героев Dota 2 по изображению с применением Transfer Learning на базе EfficientNetB0.
- Обзор проекта
- Структура файлов
- Набор данных
- Предобработка и аугментация
- Архитектура нейронной сети
- Функция потерь — Focal Loss
- Расписание скорости обучения — Warmup Cosine Decay
- Стратегия обучения — двухфазный Transfer Learning
- Callbacks и регуляризация
- Оценка модели — TTA и метрики
- Результаты
- Требования и запуск
Лабораторная работа реализует систему распознавания 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)
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 содержат только синтетику, а не оригиналы.
Перед подачей в модель каждый пиксель нормализуется согласно ожиданиям EfficientNetB0:
def eff_prep(x):
return tf.keras.applications.efficientnet.preprocess_input(x)Функция масштабирует значения пикселей из [0, 255] в диапазон [-1, 1], как требуется EfficientNet.
Применяется только к обучающей выборке в процессе обучения:
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", # заполнение границ отражением
)
Онлайн-аугментация случайным образом искажает изображение при каждой итерации, тем самым искусственно увеличивая эффективный объём обучающей выборки и снижая переобучение.
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— линейное преобразование + нелинейная активация ReLUBatchNormalization— нормализует активации по мини-батчу, ускоряет обучение и стабилизирует градиентыDropout— случайно обнуляет нейроны с заданной вероятностью (0.40 и 0.30), предотвращая переобучение
Выходной слой Dense(124, softmax)
Преобразует вектор признаков в вектор вероятностей длиной 124 (по числу героев). softmax гарантирует, что сумма вероятностей равна 1.
При многоклассовой классификации стандартная Cross-Entropy Loss присваивает одинаковый вес "лёгким" (уверенно классифицируемым) и "трудным" примерам. Focal Loss решает эту проблему, снижая вес лёгких примеров.
где:
-
$p_t$ — предсказанная вероятность для правильного класса -
$\gamma$ (gamma) — фокусирующий параметр (в работе:$\gamma = 2.0$ ) -
$(1 - p_t)^{\gamma}$ — модулирующий множитель: если пример классифицирован уверенно ($p_t \to 1$ ), множитель$\to 0$ и потеря минимальна
К Focal Loss добавлено сглаживание меток (label_smoothing=0.1):
Вместо "жёстких" меток 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)При старте обучения веса backbone случайны (или требуют адаптации). Слишком большой LR в начале дестабилизирует обучение. Linear Warmup постепенно увеличивает LR от 0 до base_lr за первые warmup_steps.
После прогрева LR убывает по косинусу:
Это обеспечивает плавное снижение LR, избегая резких изменений градиента в конце обучения.
| Параметр | Значение |
|---|---|
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)Цель: Обучить классификационную голову при замороженном 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. Обучение быстрое и стабильное, поскольку предобученные веса не изменяются.
Цель: Дообучить 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.
- Предотвращение разрушения признаков: Если сразу обучать весь backbone на высоком LR, предобученные веса ImageNet "забудутся" слишком быстро.
- Эффективность: Фаза 1 быстро достигает разумного стартового состояния для головы.
- Стабильность: Малый LR в фазе 2 + cosine decay обеспечивают точную настройку без дестабилизации.
EarlyStopping(
monitor="val_accuracy",
patience=7, # фаза 1; patience=12 в фазе 2
restore_best_weights=True
)Прекращает обучение, если val_accuracy не улучшается patience эпох подряд. restore_best_weights=True гарантирует возврат к весам наилучшей эпохи.
ModelCheckpoint(
"best_phase1.keras", # "best_final.keras" в фазе 2
monitor="val_accuracy",
save_best_only=True
)Сохраняет модель только при улучшении метрики. Это позволяет безопасно прервать обучение в любой момент без потери лучших весов.
В Dense-слоях применяется L2-регуляризация:
kernel_regularizer=l2(5e-5)Штрафует большие веса, добавляя λ * sum(w²) к функции потерь. Снижает переобучение.
Нормализует активации по мини-батчу:
Ускоряет обучение, допускает более высокий LR, действует как лёгкий регуляризатор.
Случайно обнуляет нейроны с вероятностью p во время обучения:
- После Dense(768):
p = 0.40 - После Dense(384):
p = 0.30
Во время инференса dropout отключается; активации масштабируются на (1 - p).
TTA — техника, при которой одно изображение предсказывается N раз с разными случайными аугментациями, а результаты усредняются:
В работе используется 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:
Per-hero F1 позволяет выявить героев, которых модель распознаёт плохо (confusion matrix и нижний/верхний рейтинг).
Матрица ошибок 124×124 показывает, какие герои чаще всего путаются между собой. Визуализируется как тепловая карта.
| Метрика | Без 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%
Топ-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 | Оценка качества на несбалансированных классах |



































































