Привет, Хабр! Публикуем материал выпускника нашей программы Deep Learning и координатора программы по большим данным, Кирилла Данилюка о его опыте использования фреймворка компьютерного зрения OpenCV для определения линий дорожной разметки.

image

Некоторое время назад я начал программу от Udacity: “Self-Driving Car Engineer Nanodegree”. Она состоит из множества проектов по различным аспектам построения системы вождения на автопилоте. Представляю вашему вниманию мое решение к первому проекту: простой линейный детектор дорожной разметки. Чтобы понять, что в итоге получилось, посмотрите сначала видео:



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

Принцип работы детектора


Процесс построения детектора состоит из трех основных шагов:

  1. Предобработка данных, фильтрация от шума и векторизация изображения.
  2. Обновление состояния линий дорожной разметки по данным из первого шага.
  3. Рисование обновленных линий и других объектов на исходном изображении.

Сначала на вход функции image_pipeline подается 3-канальное изображение формата RGB, которое затем фильтруется, преобразовывается, а внутри функции обновляются объекты Line и Lane . Затем поверх самого изображения рисуются все необходимые элементы, как показано ниже:

image

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

Шаг 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.

image

После перевода изображения в HSV некоторые рекомендуют применить размытие по Гауссу, но в моём случае оно снизило качество распознавания. Следующая стадия — бинаризация (преобразование изображения в бинарную маску с интересующими нас цветами: оттенками желтого и белого).

image

Наконец, мы готовы векторизировать наше изображение. Применим два преобразования:

  1. Детектор границ Кэнни: алгоритм оптимального определения границ, который рассчитывает градиенты интенсивности изображения, а затем с помощью двух порогов удаляет слабые границы, оставляя искомые (мы используем (280, 360) ) как пороговые значения в функции canny .
  2. Преобразование Хафа: получив границы с помощью алгоритма Кэнни, мы можем соединить их с помощью линий. Я не хочу вдаваться в математику алгоритма — она достойна отдельного поста — эта ссылка или ссылка выше поможет вам, если вас заинтересовал метод. Главное, что, применив это преобразование, мы получаем набор линий, каждая из которых, после небольшой дополнительной обработки и фильтрации, становится экземпляром класса Line с известным углом наклона и свободным членом.


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

Линию горизонта (vanishing point) можно определить по той точке, в которой сходится правая и левая полоса движения.

Шаг 2: Обновление линий дорожной разметки


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

Для того, чтобы облегчить процесс, я решил использовать ООП и представлять линии дорожной разметки как экземпляры класса Lane : Lane.left_line, Lane.right_line . Некоторые студенты ограничились добавлением объекта `lane` в глобальный неймспейс, но я не фанат глобальных переменных в коде.

Рассмотрим подробнее классы Lane и Line и их экземпляры:

Каждый экземпляр класса Line представляет собой отдельную линию: кусок дорожной разметки или просто любую линию, которая будет определена преобразованием Хафа, в то время как главная цель объектов класса Lane — выявлять, является ли данная линия сегментом дорожной разметки. Чтобы это сделать, будем руководствоваться следующей логикой:

  1. Линия не может быть горизонтальной и должна иметь умеренный уклон.
  2. Разница между уклонами линии дорожной разметки и линии-кандидата не может быть слишком высокой.
  3. Линия-кандидат не должна отстоять далеко от дорожной разметки, к которой она принадлежит.
  4. Линия-кандидат должна быть ниже горизонта

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

Класс Lane является контейнером для левой и правой линии разметки (рефакторинг так и просится). В классе также представлено несколько методов, относящихся к работе с линиями разметки, самый важный из которых fit_lane_line . Для того, чтобы создать новую линию разметки, я представляю подходящие сегменты разметки в виде точек, а затем аппроксимирую их полиномом первого порядка (то есть линией) с помощью обычной функции numpy.polyfit

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

  1. Буферы. Полученная линия разметки запоминает N предыдущих состояний и последовательно добавляет состояние линии разметки на текущем кадре в буфер.
  2. Дополнительная фильтрация линий с учётом данных в буфере. Если после преобразования и очистки мы не смогли избавиться от шума в данных, то есть вероятность, что наша линия окажется выбросом, а, как мы знаем, линейная модель чувствительна к выбросам. Поэтому для нас принципиально высокое значение точности — даже в ущерб значительной потери полноты. Проще говоря, лучше отфильтровать правильную линию, чем добавить в модель выброс. Специально для таких случаев, я создал DECISION_MAT — матрицу “принятия решения”, которая решает, как соотнести текущий уклон линии и среднее по всем линиям в буфере.

Например, для DECISION_MAT = [ [ 0.1, 0.9] , [1, 0] ] мы рассматриваем выбор из двух решений: считать линию нестабильной (т.е. потенциальным выбросом), либо стабильной (ее наклон соответствует среднему наклону линий данной полосы в буфере плюс/минус пороговое значение). Если линия нестабильна, мы всё равно хотим не потерять её: она может нести информацию о реальном повороте дороги. Просто учитывать её мы будем с маленьким коэффициентом (в данном случае — 0.1) Для стабильной линии мы просто будем использовать ее текущие параметры без какого либо взвешивания по предыдущим данным.

Индикатор стабильности линии разметки в текущем кадре описывается объектами класса Lane : Lane.right_lane.stable и Lane.left_lane.stable , которые являются булевыми. Если хотя бы одна из данных переменных принимает значение False , я визуализирую это как красный полигон между двумя линиями (ниже вы сможете увидеть, как это выглядит).

В результате мы получаем достаточно стабильные линии:



Шаг 3: Рисование и обновление исходного изображения


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

  1. Ограничить экстраполяцию линий разметки данной точкой.
  2. Отфильтровать все линии Хафа, находящиеся выше горизонта.

Для визуализации всего процесса определения полос, я сделал небольшое 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 на английском языке.
Поделиться с друзьями
-->

Комментарии (8)


  1. Tsyshnatiy
    11.05.2017 17:56
    +1

    >> Сделать проекцию дороги как «вид сверху» — это значительно упростит определение полос.
    Как раз писал года полтора назад в качестве упражнения BirdsEyeTransform.
    Посмотрите пожалуйста, мб поможет. Кроме того, там есть питонячий вариант, правда не моего авторства, а подсмотренный впоследствии.
    https://bitbucket.org/Tsyshnatiy/bev

    Ну и бумажка:
    http://www.ijser.org/researchpaper%5CA-Simple-Birds-Eye-View-Transformation-Technique.pdf


    1. BelBES
      11.05.2017 22:02
      +1

      В OpenCV оно реализуется одной функцией warpperspective с правильно подобранными параметрами преобразования (которые можно и на глаз подобрать для конкретного камерного сетапа).


    1. kirilldaniluk
      12.05.2017 00:52

      Спасибо!


  1. BelBES
    11.05.2017 22:08

    А вы продвинулись дальше первого урока про LDW в этом курсе? Там что-то стоящее есть?
    А то что-то тестовый урок вообще не впечатлял и не мотивировал к выкладыванию пары тысяч баксов за него :-)


    1. kirilldaniluk
      12.05.2017 00:52

      Продвинулся. Насчёт стоящего — вопрос сложный, я ведь деньги заплатил, поэтому уже необъективен :)

      Вообще, в плане чистых знаний Udacity сам по себе мало что даст: всё и так давно выложено в открытый доступ, а обучающие материалы в Udacity поверхностны. Лично для меня ценность скорее в дедлайнах, сертификате и общей методологии. Плюс, Sebastian Thrun, их СЕО, и развил в Гугле self-driving car, так что курс получился «из первых рук». Программа значительно лучше продумана и более требовательна, чем, например, их ML Nanodegree (который я прошёл, так что есть с чем сравнивать). Term 1 маркетинговый, конечно, но я купился на Term 2 и 3, в которых хайп заканчивается и начинается работа на C++.


      1. BelBES
        12.05.2017 12:40

        Насчёт стоящего — вопрос сложный, я ведь деньги заплатил, поэтому уже необъективен :)

        Я имел в виду: там все задания в стиле: "вот вам кусок кода, покрутите у него параметры так, чтобы работало как у нас"? А то на вид вроде бы что-то интересное может быть, но если все в стиле первой главы про LDW, то программа ни о чем...


        1. kirilldaniluk
          12.05.2017 13:29

          Мне кажется, интереса в проект можно добавить и самому. Для факта сдачи этого проекта, например, требовалось почти ничего, но многие студенты не ограничиваются базовым заданием и придумывают-исследуют что-то дополнительно. Udacity даёт направление, а копаешь уже сам.

          В следующем проекте (классификатор дорожных знаков на свёрточных сетях), кстати, несколько студентов добились state-of-the-art точности, что совсем не тривиально, хотя от них никто этого не требовал. Но именно такие посты потом и интересно читать :)


  1. SerjkFrog
    17.05.2017 09:21

    С лесом, конечно, все печально, но я верю, что не все потеряно)