Привет! Публикуем материал выпускника нашей программы Deep Learning и координатора программы по большим данным, Кирилла Данилюка о его опыте использования фреймворка компьютерного зрения OpenCV для определения линий дорожной разметки.
Некоторое время назад я начал программу от Udacity: “Self-Driving Car Engineer Nanodegree”. Она состоит из множества проектов по различным аспектам построения системы вождения на автопилоте. Представляю вашему вниманию мое решение к первому проекту: простой линейный детектор дорожной разметки. Чтобы понять, что в итоге получилось, посмотрите сначала видео:
Цель данного проекта — построить простую линейную модель для покадрового распознавания полос движения: на вход получаем кадр, серией трансформаций, о которых поговорим далее, обрабатываем его, получаем отфильтрованное изображение, которое можно векторизовать и обучить две независимых линейных регрессии: по одной для каждой полосы. Проект намеренно простой: только линейная модель, только хорошие погодные условия и видимость, только две линии разметки. Естественно, это не продакшн-решение, однако даже такой проект позволяет вдоволь наиграться с OpenCV, фильтрами и, в целом, помогает почувствовать, с какими трудностями сталкиваются разработчики автопилотов в автомобилях.
Принцип работы детектора
Процесс построения детектора состоит из трех основных шагов:
- Предобработка данных, фильтрация от шума и векторизация изображения.
- Обновление состояния линий дорожной разметки по данным из первого шага.
- Рисование обновленных линий и других объектов на исходном изображении.
Сначала на вход функции image_pipeline
подается 3-канальное изображение формата RGB, которое затем фильтруется, преобразовывается, а внутри функции обновляются объекты Line
и Lane
. Затем поверх самого изображения рисуются все необходимые элементы, как показано ниже:
Я старался подходить к задаче в стиле ООП (в отличие от большинства аналитических задач): так, чтобы каждый из шагов получился изолированным от других.
Шаг 1: Предварительная обработка и векторизация
Первая стадия нашей работы хорошо знакома data scientist-ам и всем, кто работает с “сырыми” данными: сперва мы должны сделать предобработку данных, а затем векторизовать в понятный для алгоритмов вид. Общий пайплайн для предобработки и векторизации исходного изображения следующий:
blank_image = np.zeros_like(image)
hsv_image = cv2.cvtColor(image, cv2.COLOR_RGB2HSV)
binary_mask = get_lane_lines_mask(hsv_image, [WHITE_LINES, YELLOW_LINES])
masked_image = draw_binary_mask(binary_mask, hsv_image)
edges_mask = canny(masked_image, 280, 360)
# Correct initialization is important, we cheat only once here!
if not Lane.lines_exist():
edges_mask = region_of_interest(edges_mask, ROI_VERTICES)
segments = hough_line_transform(edges_mask, 1, math.pi / 180, 5, 5,
В нашем проекте используется OpenCV — один из самых популярных фреймворков для работы с изображениями на пиксельном уровне с помощью матричных операций.
Сначала мы преобразуем исходное RGB-изображение в HSV — именно в этой цветовой модели удобно выделять диапазоны конкретных цветов (а нас интересуют оттенки жёлтого и белого для определения полос движения).
Обратите внимание на скриншот ниже: выделить «всё жёлтое» в RGB гораздо сложнее, чем в HSV.
После перевода изображения в HSV некоторые рекомендуют применить размытие по Гауссу, но в моём случае оно снизило качество распознавания. Следующая стадия — бинаризация (преобразование изображения в бинарную маску с интересующими нас цветами: оттенками желтого и белого).
Наконец, мы готовы векторизировать наше изображение. Применим два преобразования:
- Детектор границ Кэнни: алгоритм оптимального определения границ, который рассчитывает градиенты интенсивности изображения, а затем с помощью двух порогов удаляет слабые границы, оставляя искомые (мы используем
(280, 360)
) как пороговые значения в функцииcanny
. - Преобразование Хафа: получив границы с помощью алгоритма Кэнни, мы можем соединить их с помощью линий. Я не хочу вдаваться в математику алгоритма — она достойна отдельного поста — эта ссылка или ссылка выше поможет вам, если вас заинтересовал метод. Главное, что, применив это преобразование, мы получаем набор линий, каждая из которых, после небольшой дополнительной обработки и фильтрации, становится экземпляром класса Line с известным углом наклона и свободным членом.
Очевидно, что верхняя часть изображения вряд ли будет содержать линии разметки, поэтому её можно не принимать в расчёт. Способов два: либо сразу закрасить верх нашей бинарной маски черным, либо подумать над более умной фильтрацией линий. Я выбрал второй способ: я посчитал, что всё, что находится выше линии горизонта, не может быть линией разметки.
Линию горизонта (vanishing point) можно определить по той точке, в которой сходится правая и левая полоса движения.
Шаг 2: Обновление линий дорожной разметки
Обновление линий дорожной разметки будет происходить с помощью функции update_lane(segments)
в image_pipeline
, которая на вход получает объекты segments
с последнего шага (которые на самом деле являются объектами Line
из преобразования Хафа).
Для того, чтобы облегчить процесс, я решил использовать ООП и представлять линии дорожной разметки как экземпляры класса Lane
: Lane.left_line, Lane.right_line
. Некоторые студенты ограничились добавлением объекта `lane` в глобальный неймспейс, но я не фанат глобальных переменных в коде.
Рассмотрим подробнее классы Lane
и Line
и их экземпляры:
Каждый экземпляр класса Line
представляет собой отдельную линию: кусок дорожной разметки или просто любую линию, которая будет определена преобразованием Хафа, в то время как главная цель объектов класса Lane
— выявлять, является ли данная линия сегментом дорожной разметки. Чтобы это сделать, будем руководствоваться следующей логикой:
- Линия не может быть горизонтальной и должна иметь умеренный уклон.
- Разница между уклонами линии дорожной разметки и линии-кандидата не может быть слишком высокой.
- Линия-кандидат не должна отстоять далеко от дорожной разметки, к которой она принадлежит.
- Линия-кандидат должна быть ниже горизонта
Таким образом, для определения принадлежности к линии разметки мы используем достаточно тривиальную логику: принимаем решения исходя из уклона линии и расстояния до разметки. Способ неидеальный, но он сработал для моих простых условий
Класс Lane
является контейнером для левой и правой линии разметки (рефакторинг так и просится). В классе также представлено несколько методов, относящихся к работе с линиями разметки, самый важный из которых fit_lane_line
. Для того, чтобы создать новую линию разметки, я представляю подходящие сегменты разметки в виде точек, а затем аппроксимирую их полиномом первого порядка (то есть линией) с помощью обычной функции numpy.polyfit
Стабилизация полученных линий дорожной разметки очень важна: исходное изображение очень зашумлено, а определение полос происходит покадрово. Любая тень или неоднородность дорожного покрытия сразу меняет цвет разметки на такой, который наш детектор определить не в состоянии… В процессе работы я использовал несколько способов стабилизации:
- Буферы. Полученная линия разметки запоминает N предыдущих состояний и последовательно добавляет состояние линии разметки на текущем кадре в буфер.
- Дополнительная фильтрация линий с учётом данных в буфере. Если после преобразования и очистки мы не смогли избавиться от шума в данных, то есть вероятность, что наша линия окажется выбросом, а, как мы знаем, линейная модель чувствительна к выбросам. Поэтому для нас принципиально высокое значение точности — даже в ущерб значительной потери полноты. Проще говоря, лучше отфильтровать правильную линию, чем добавить в модель выброс. Специально для таких случаев, я создал
DECISION_MAT
— матрицу “принятия решения”, которая решает, как соотнести текущий уклон линии и среднее по всем линиям в буфере.
Например, для DECISION_MAT = [ [ 0.1, 0.9] , [1, 0] ]
мы рассматриваем выбор из двух решений: считать линию нестабильной (т.е. потенциальным выбросом), либо стабильной (ее наклон соответствует среднему наклону линий данной полосы в буфере плюс/минус пороговое значение). Если линия нестабильна, мы всё равно хотим не потерять её: она может нести информацию о реальном повороте дороги. Просто учитывать её мы будем с маленьким коэффициентом (в данном случае — 0.1) Для стабильной линии мы просто будем использовать ее текущие параметры без какого либо взвешивания по предыдущим данным.
Индикатор стабильности линии разметки в текущем кадре описывается объектами класса Lane
: Lane.right_lane.stable
и Lane.left_lane.stable
, которые являются булевыми. Если хотя бы одна из данных переменных принимает значение False
, я визуализирую это как красный полигон между двумя линиями (ниже вы сможете увидеть, как это выглядит).
В результате мы получаем достаточно стабильные линии:
Шаг 3: Рисование и обновление исходного изображения
Для того, чтобы линии были нарисованы корректно, я написал довольно простой алгоритм, который вычисляет координаты точки горизонта, о которой мы уже с вами говорили. В моем проекте данная точка нужна для двух вещей:
- Ограничить экстраполяцию линий разметки данной точкой.
- Отфильтровать все линии Хафа, находящиеся выше горизонта.
Для визуализации всего процесса определения полос, я сделал небольшое image augmentation
:
def draw_some_object(what_to_draw, background_image_to_draw_on, **kwargs):
# do_stuff_and_return_image
# Snapshot 1
out_snap1 = np.zeros_like(image)
out_snap1 = draw_binary_mask(binary_mask, out_snap1)
out_snap2 = draw_filtered_lines(segments, out_snap1)
snapshot1 = cv2.resize(deepcopy(out_snap1), (240,135))
# Snapshot 2
out_snap2 = np.zeros_like(image)
out_snap2 = draw_canny_edges(edges_mask, out_snap2)
out_snap2 = draw_points(Lane.left_line.points, out_snap2, Lane.COLORS['left_line'])
out_snap2 = draw_points(Lane.right_line.points, out_snap2, Lane.COLORS['right_line'])
out_snap2 = draw_lane_polygon(out_snap2)
snapshot2 = cv2.resize(deepcopy(out_snap2), (240,135))
# Augmented image
output = deepcopy(image)
output = draw_lane_lines([Lane.left_line, Lane.right_line], output, shade_background=True)
output = draw_lane_polygon(output)
output = draw_dashboard(output, snapshot1, snapshot2)
return output
Как видно из кода, я накладываю на исходное видео два изображения: одно с бинарной маской, второе — с прошедшими все наши фильтры линиями Хафа (трансформированными в точки). На само исходное видео я накладываю две полосы движения (линейная регрессия над точками из предыдущего изображения). Зелёный прямоугольник — индикатор наличия «нестабильных» линий: при их наличии он становится красным. Использование такой архитектуры позволяет достаточно легко менять и комбинировать кадры, которые будут высвечиваться в качестве дашборда, позволяя одновременно визуализировать множество компонентов и все это — без каких либо значительных изменений в исходном коде.
Что дальше?
Данный проект еще очень далек от завершения: чем больше я работаю над ним, тем больше вещей, требующих улучшения, я нахожу:
- Сделать детектор нелинейным, чтобы он мог с успехом работать, к примеру, в горах, где повороты на каждом шагу.
- Сделать проекцию дороги как «вид сверху» — это значительно упростит определение полос.
- Распознавание дороги. Было бы замечательно распознавать не только разметку, но и саму дорогу, что значительно облегчит работу детектора.
Весь исходный код проекта доступен на GitHub по ссылке.
P.S. А теперь сломаем все!
Конечно, в этом посте должна быть и забавная часть. Давайте посмотрим, как жалок становится детектор на горной дороге с частыми сменами направления и освещённости. Сначала всё, вроде бы, нормально, но в дальнейшем ошибка в определении полос накапливается, и детектор перестаёт успевать следить за ними:
А в лесу, где свет меняется очень быстро, наш детектор полностью провалил задание:
Кстати, один из следующих проектов — сделать нелинейный детектор, который как раз и справится с «лесным» заданием. Следите за новыми постами!
→ Исходный пост в Medium на английском языке.
Автор: anastasiagrishina