Компьютерное зрение на практике: детектируем объекты на видео с помощью OpenCV и Python
Шаг за шагом строим пайплайн обработки видеопотока: захват кадров, фоновое вычитание, трекинг объектов. Весь код — рабочий, с объяснением каждого параметра.
Содержание
Компьютерное зрение на практике: детектируем объекты на видео с помощью OpenCV и Python
Компьютерное зрение — одна из тех областей, где разрыв между теорией и практикой ощущается особенно остро. Можно прочитать десяток статей о свёрточных сетях и алгоритмах детекции, но первый же реальный видеопоток с шумом, переменным освещением и перекрывающимися объектами ставит в тупик. Именно поэтому имеет смысл начинать не с нейросетей, а с классических инструментов — и прежде всего с OpenCV.
В этой статье мы построим полноценный пайплайн обработки видеопотока: захватим кадры с камеры или из файла, выделим движущиеся объекты через фоновое вычитание, применим морфологические операции для очистки маски, найдём контуры и реализуем простой трекинг. Весь код рабочий, каждый параметр объяснён — не для галочки, а чтобы вы понимали, что происходит и почему.
Что такое пайплайн обработки видео и зачем он нужен
Видео — это последовательность кадров. Звучит банально, но именно из этого факта вытекает вся архитектура работы с видеопотоком: мы обрабатываем кадры по одному, применяя к каждому набор операций. Этот набор и называют пайплайном.
Типичный пайплайн детекции объектов на видео выглядит так:
- Захват кадра — получаем изображение из источника.
- Предобработка — приводим кадр к нужному формату (оттенки серого, размытие).
- Детекция — выделяем интересующие области (объекты, движение).
- Постобработка — убираем шум, объединяем фрагменты.
- Трекинг — связываем объекты между кадрами.
- Визуализация — рисуем bounding box'ы, метки, траектории.
Каждый этап влияет на следующий. Плохое размытие на шаге 2 — и детектор на шаге 3 будет находить десятки ложных объектов. Пропустили постобработку — трекер будет путаться. Разберём каждый шаг подробно.
Захват видеопотока
OpenCV предоставляет класс VideoCapture, который умеет работать как с файлами, так и с камерами.
import cv2
# Захват с веб-камеры (индекс 0 — первая доступная камера)
cap = cv2.VideoCapture(0)
# Или захват из файла:
# cap = cv2.VideoCapture('traffic.mp4')
if not cap.isOpened():
raise RuntimeError("Не удалось открыть источник видео")
# Получаем параметры потока
fps = cap.get(cv2.CAP_PROP_FPS)
width = int(cap.get(cv2.CAP_PROP_FRAME_WIDTH))
height = int(cap.get(cv2.CAP_PROP_FRAME_HEIGHT))
print(f"Разрешение: {width}x{height}, FPS: {fps}")
Важный момент: cap.get(cv2.CAP_PROP_FPS) для веб-камеры часто возвращает 0 или неверное значение — это нормально, реальный FPS определяется скоростью захвата и обработки. Для файлов значение обычно корректное.
Основной цикл чтения кадров
while True:
ret, frame = cap.read()
# ret — булево значение: True, если кадр успешно считан
if not ret:
print("Поток завершён или кадр не получен")
break
# Здесь будет вся обработка
cv2.imshow('Frame', frame)
# Выход по нажатию 'q'
if cv2.waitKey(1) & 0xFF == ord('q'):
break
cap.release()
cv2.destroyAllWindows()
cv2.waitKey(1) — задержка в 1 миллисекунду. Это не просто пауза: без вызова waitKey окно OpenCV не будет обновляться. Значение & 0xFF нужно для корректной работы на 64-битных системах, где waitKey может возвращать значения больше 255.
Предобработка кадра
Сырой кадр с камеры содержит цветовые шумы, артефакты сжатия и другие помехи. Перед детекцией его нужно подготовить.
def preprocess_frame(frame):
# Переводим в оттенки серого — большинство алгоритмов детекции
# работают с одноканальным изображением
gray = cv2.cvtColor(frame, cv2.COLOR_BGR2GRAY)
# Гауссово размытие убирает высокочастотный шум.
# (21, 21) — размер ядра (должен быть нечётным),
# 0 — стандартное отклонение (вычисляется автоматически из размера ядра)
blurred = cv2.GaussianBlur(gray, (21, 21), 0)
return blurred
Размер ядра размытия — один из ключевых параметров. Слишком маленькое ядро (например, 3×3) почти не убирает шум. Слишком большое (51×51) размоет реальные границы объектов, и детектор их пропустит. Для большинства задач хорошо работают значения от 5×5 до 21×21 — зависит от разрешения и характера шума.
Фоновое вычитание
Фоновое вычитание (background subtraction) — классический подход к детекции движения. Идея проста: если у нас есть модель «пустой» сцены, всё, что от неё отличается, — это объекты переднего плана.
OpenCV предоставляет несколько готовых алгоритмов. Два наиболее используемых:
- MOG2 (Mixture of Gaussians v2) — моделирует каждый пиксель как смесь гауссовых распределений. Хорошо справляется с постепенными изменениями освещения.
- KNN (K-Nearest Neighbours) — более точен при резких изменениях фона, но медленнее.
# Создаём вычитатель фона
# history=500 — количество кадров для построения модели фона
# varThreshold=16 — порог чувствительности (чем выше, тем меньше ложных срабатываний)
# detectShadows=True — алгоритм будет отмечать тени серым цветом (127)
bg_subtractor = cv2.createBackgroundSubtractorMOG2(
history=500,
varThreshold=16,
detectShadows=True
)
def apply_background_subtraction(frame):
# Применяем вычитание фона
# learningRate=-1 означает автоматический выбор скорости обучения
fg_mask = bg_subtractor.apply(frame, learningRate=-1)
# Убираем тени: заменяем серые пиксели (127) чёрными (0)
# Оставляем только белые пиксели (255) — реальные объекты переднего плана
_, fg_mask = cv2.threshold(fg_mask, 200, 255, cv2.THRESH_BINARY)
return fg_mask
Подводные камни фонового вычитания
Проблема 1: Медленная инициализация. Первые history кадров алгоритм строит модель фона — в этот период маска будет «шумной». Для файлов можно прогнать несколько секунд видео без обработки, чтобы модель стабилизировалась.
Проблема 2: Статичные объекты. Если объект остановился, через несколько секунд он «поглощается» фоном — алгоритм начнёт считать его частью сцены. Это фундаментальное ограничение метода, а не баг.
Проблема 3: Освещение. Резкое изменение освещения (облако закрыло солнце, включился свет) даёт всплеск ложных срабатываний. Параметр varThreshold помогает, но не решает проблему полностью.
Морфологические операции: очищаем маску
После фонового вычитания маска обычно содержит дыры внутри объектов и мелкий шум снаружи. Морфологические операции позволяют это исправить.
def clean_mask(fg_mask):
# Ядро для морфологических операций
kernel = cv2.getStructuringElement(cv2.MORPH_ELLIPSE, (5, 5))
# Эрозия убирает мелкие белые точки (шум)
# Но также уменьшает размер реальных объектов
eroded = cv2.erode(fg_mask, kernel, iterations=1)
# Дилатация восстанавливает размер объектов
# и заполняет небольшие дыры
dilated = cv2.dilate(eroded, kernel, iterations=3)
# Закрытие (closing) = дилатация + эрозия
# Хорошо заполняет дыры внутри объектов
closed = cv2.morphologyEx(dilated, cv2.MORPH_CLOSE, kernel, iterations=2)
return closed
Последовательность операций имеет значение. Сначала эрозия убирает шум, потом дилатация восстанавливает объекты — это называется «открытие» (opening). Закрытие делает обратное: сначала дилатация, потом эрозия, что заполняет дыры. Комбинируя их, можно добиться чистой маски даже при сложном фоне.
Поиск контуров и фильтрация объектов
Теперь из маски нужно извлечь отдельные объекты. OpenCV находит контуры — замкнутые кривые, описывающие границы белых областей.
def find_objects(fg_mask, min_area=500):
# cv2.RETR_EXTERNAL — только внешние контуры (без вложенных)
# cv2.CHAIN_APPROX_SIMPLE — сжатое представление (только угловые точки)
contours, _ = cv2.findContours(
fg_mask,
cv2.RETR_EXTERNAL,
cv2.CHAIN_APPROX_SIMPLE
)
objects = []
for contour in contours:
area = cv2.contourArea(contour)
# Фильтруем слишком маленькие контуры — это шум
if area < min_area:
continue
# Получаем ограничивающий прямоугольник
x, y, w, h = cv2.boundingRect(contour)
# Центр объекта
cx = x + w // 2
cy = y + h // 2
objects.append({
'bbox': (x, y, w, h),
'center': (cx, cy),
'area': area
})
return objects
Параметр min_area — критически важный порог. Слишком маленькое значение — и вы будете трекать каждую пылинку. Слишком большое — пропустите реальные объекты. Подбирается эмпирически под конкретную задачу: для пешеходов в кадре видеонаблюдения это обычно 1000–3000 пикселей, для транспорта — 5000 и выше.
Простой трекинг объектов
Детекция находит объекты в каждом кадре независимо. Трекинг — это связывание обнаруженных объектов между кадрами, чтобы понять: «этот объект в кадре N — тот же, что в кадре N-1».
Реализуем простой трекер на основе евклидова расстояния. Это не самый точный метод (существуют SORT, DeepSORT, ByteTrack), но он прозрачен и хорошо объясняет принцип.
import numpy as np
from collections import OrderedDict
class CentroidTracker:
def __init__(self, max_disappeared=30):
# Счётчик для присвоения уникальных ID
self.next_object_id = 0
# Словарь: ID -> центроид
self.objects = OrderedDict()
# Словарь: ID -> количество кадров без обнаружения
self.disappeared = OrderedDict()
# Максимум кадров без обнаружения до удаления объекта
self.max_disappeared = max_disappeared
def register(self, centroid):
"""Регистрируем новый объект с уникальным ID."""
self.objects[self.next_object_id] = centroid
self.disappeared[self.next_object_id] = 0
self.next_object_id += 1
def deregister(self, object_id):
"""Удаляем объект из трекера."""
del self.objects[object_id]
del self.disappeared[object_id]
def update(self, input_centroids):
"""
Обновляем трекер новыми центроидами.
Возвращает словарь ID -> центроид.
"""
# Если нет входных данных — увеличиваем счётчик исчезновения
if len(input_centroids) == 0:
for object_id in list(self.disappeared.keys()):
self.disappeared[object_id] += 1
if self.disappeared[object_id] > self.max_disappeared:
self.deregister(object_id)
return self.objects
# Если трекер пуст — регистрируем все объекты
if len(self.objects) == 0:
for centroid in input_centroids:
self.register(centroid)
return self.objects
# Вычисляем матрицу расстояний между существующими
# и новыми центроидами
object_ids = list(self.objects.keys())
object_centroids = list(self.objects.values())
# Матрица расстояний: строки — существующие объекты,
# столбцы — новые центроиды
D = np.linalg.norm(
np.array(object_centroids)[:, np.newaxis] -
np.array(input_centroids)[np.newaxis, :],
axis=2
)
# Жадное сопоставление: для каждого существующего объекта
# находим ближайший новый центроид
rows = D.min(axis=1).argsort()
cols = D.argmin(axis=1)[rows]
used_rows = set()
used_cols = set()
for (row, col) in zip(rows, cols):
if row in used_rows or col in used_cols:
continue
# Если расстояние слишком большое — не сопоставляем
if D[row, col] > 100:
continue
object_id = object_ids[row]
self.objects[object_id] = input_centroids[col]
self.disappeared[object_id] = 0
used_rows.add(row)
used_cols.add(col)
# Необработанные строки — объекты, которые не нашли пару
unused_rows = set(range(D.shape[0])) - used_rows
for row in unused_rows:
object_id = object_ids[row]
self.disappeared[object_id] += 1
if self.disappeared[object_id] > self.max_disappeared:
self.deregister(object_id)
# Необработанные столбцы — новые объекты
unused_cols = set(range(D.shape[1])) - used_cols
for col in unused_cols:
self.register(input_centroids[col])
return self.objects
Порог расстояния 100 пикселей — ещё один эмпирический параметр. Он должен быть больше максимального смещения объекта между кадрами, но меньше расстояния между соседними объектами. Для видео с высоким FPS объекты смещаются мало — порог можно снизить. Для низкого FPS — увеличить.
Тем, кто хочет разобраться с OpenCV системно — от базовых операций с изображениями до сложных пайплайнов, — стоит посмотреть на специализированный курс по OpenCV: там эти концепции разбираются последовательно, с практическими задачами на реальных данных.
Собираем всё вместе
import cv2
import numpy as np
def draw_results(frame, tracked_objects, objects_data):
"""Визуализируем результаты на кадре."""
# Рисуем bounding box'ы
for obj in objects_data:
x, y, w, h = obj['bbox']
cv2.rectangle(frame, (x, y), (x + w, y + h), (0, 255, 0), 2)
# Рисуем ID трекера
for object_id, centroid in tracked_objects.items():
cx, cy = centroid
cv2.circle(frame, (cx, cy), 4, (0, 0, 255), -1)
cv2.putText(
frame,
f"ID {object_id}",
(cx - 10, cy - 10),
cv2.FONT_HERSHEY_SIMPLEX,
0.5,
(0, 0, 255),
2
)
return frame
def main():
cap = cv2.VideoCapture(0) # или путь к файлу
bg_subtractor = cv2.createBackgroundSubtractorMOG2(
history=500,
varThreshold=16,
detectShadows=True
)
tracker = CentroidTracker(max_disappeared=30)
kernel = cv2.getStructuringElement(cv2.MORPH_ELLIPSE, (5, 5))
while True:
ret, frame = cap.read()
if not ret:
break
# Предобработка
gray = cv2.cvtColor(frame, cv2.COLOR_BGR2GRAY)
blurred = cv2.GaussianBlur(gray, (21, 21), 0)
# Фоновое вычитание
fg_mask = bg_subtractor.apply(blurred, learningRate=-1)
_, fg_mask = cv2.threshold(fg_mask, 200, 255, cv2.THRESH_BINARY)
# Морфологическая очистка
fg_mask = cv2.erode(fg_mask, kernel, iterations=1)
fg_mask = cv2.dilate(fg_mask, kernel, iterations=3)
fg_mask = cv2.morphologyEx(fg_mask, cv2.MORPH_CLOSE, kernel, iterations=2)
# Поиск объектов
objects = find_objects(fg_mask, min_area=800)
# Обновление трекера
centroids = [obj['center'] for obj in objects]
tracked = tracker.update(centroids)
# Визуализация
result = draw_results(frame.copy(), tracked, objects)
cv2.imshow('Detection', result)
cv2.imshow('Mask', fg_mask)
if cv2.waitKey(1) & 0xFF == ord('q'):
break
cap.release()
cv2.destroyAllWindows()
if __name__ == '__main__':
main()
Типичные проблемы и как их решать
Слишком много ложных срабатываний
- Увеличьте
varThresholdв MOG2 (попробуйте 25–50). - Увеличьте
min_areaвfind_objects. - Добавьте больше итераций эрозии в очистке маски.
- Проверьте освещение: нестабильный свет — главный враг фонового вычитания.
Объекты «мигают» — то обнаруживаются, то нет
- Уменьшите
varThreshold— алгоритм стал слишком строгим. - Увеличьте
max_disappearedв трекере — объект не будет удаляться так быстро. - Проверьте размытие: возможно, ядро слишком большое и размывает границы объектов.
Несколько объектов сливаются в один
- Уменьшите ядро дилатации.
- Рассмотрите применение алгоритма водораздела (
cv2.watershed) для разделения слипшихся объектов.
Высокая нагрузка на CPU
- Уменьшите разрешение:
frame = cv2.resize(frame, (640, 480))перед обработкой. - Обрабатывайте не каждый кадр:
if frame_count % 2 == 0: process(frame). - Переведите горячие места на NumPy-операции вместо Python-циклов.
Куда двигаться дальше
Описанный пайплайн — хорошая отправная точка, но у него есть потолок. Фоновое вычитание плохо работает при движущейся камере, сложном фоне и перекрытиях. Для более сложных сценариев следующие шаги:
- Оптический поток (Lucas-Kanade, Farneback) — для трекинга при движущейся камере.
- Детекторы на основе нейросетей — YOLO, SSD, EfficientDet — для точной классификации объектов.
- Современные трекеры — SORT, ByteTrack — для надёжного трекинга в толпе.
- OpenCV DNN-модуль — позволяет запускать предобученные модели прямо через OpenCV без зависимости от тяжёлых фреймворков.
Итог
Мы построили полноценный пайплайн: захват видеопотока, предобработка кадра, фоновое вычитание с MOG2, морфологическая очистка маски, поиск контуров и трекинг на основе центроидов. Каждый компонент независим и заменяем — это важное свойство хорошей архитектуры. Можно поменять алгоритм фонового вычитания на KNN, трекер на SORT, или добавить нейросетевой детектор — остальные части останутся нетронутыми.
Классический OpenCV — не устаревший инструмент, а фундамент. Понимание того, как работают эти алгоритмы на уровне пикселей и матриц, делает работу с современными нейросетевыми подходами осознанной, а не магической.
Если хочется разобраться в OpenCV системно — от базовой работы с изображениями до сложных задач компьютерного зрения, — стоит обратить внимание на курс по OpenCV: там практика поставлена в центр, а теория объясняется ровно в том объёме, который нужен для реального применения.
Комментарии
Пока нет комментариев