Введение в OpenCV применительно к распознаванию линий дорожной разметки

    Привет, Хабр! Публикуем материал выпускника нашей программы 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 на английском языке.
    • +13
    • 15,6k
    • 8

    New Professions Lab

    94,00

    Обучение в области работы с данными с 2015 г.

    Поделиться публикацией

    Похожие публикации

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

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

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

          0
          Спасибо!
          0

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

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

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

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

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

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

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

            Самое читаемое