Посмотреть на github, twitter, linkedin

В этом проекте мы пытаемся предсказать углы поворота для беспилотного автомобиля в симуляторе на основе единства. Чтобы получить наши данные, мы должны запустить симулятор и управлять автомобилем вручную (клавиатура + мышь или контроллер). Для сбора данных я использовал два разных подхода. Один набор данных состоял из того, что на машине проехали четыре круга и все время пытались оставаться посередине дороги. Другой набор известен как «данные для восстановления», когда автомобиль тронулся с места на краю дороги, и изображения были сняты, когда он «восстанавливался» или «возвращался» к середине дороги. Цель этого второго набора - добавить данные для исправления ошибок: в случае, если наша машина упадет с обочины дороги, она будет знать, что с ней делать. В прямом наборе есть 8К изображений, а в комплекте восстановления - 1,3К.

Сбор данных:

Наши данные сохраняются в таком формате:

── данные
│ ├── driving_log.csv
│ └── IMG
│ ├── center_2017_01_06_13_54_47_000.jpg
│ ├── center_2017_01_06_13_54_47_110.jpg
│ ├── left_2017_02_14_21_46_00_967.jpg
│ ├── left_2017_02_14_21_46_01_041.jpg
│ ├── right_2016_12_01_13_42_06_070.jpg
│_17_126_ right_

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

Чтобы упростить жизнь, я использовал DataFrames pandas для сортировки и организации путей к изображениям. Я не мог прочитать каждый файл изображения и сохранить его локально в своей программе, потому что у меня закончилась память, поэтому я работал с путями к изображениям. (подробнее об этом позже в разделе о генераторах).

def createDataFrame(data_path):
    """
    input: data_path: path to data
    return: data frame
    """
    data_frame = pd.read_csv(data_path)
    data_frame.columns = ['center', 'left', 'right', 'steering', 'throttle', 'brake', 'speed']
    return data_frame

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

Большинство полученных данных имели углы поворота 0 градусов, потому что большую часть трассы мы едем прямо. Вначале наша гистограмма углов поворота выглядит так:

Проблема 1. Как видите, существует 25 000 изображений с нулевым углом наклона. Данных по прямому движению в 5 раз больше, чем по левому или правому повороту. Это означает, что наша модель, вероятно, предполагает, что большую часть времени мы едем прямо.

Проблема 2: Другая проблема заключается в том, что у меня было только около 8 КБ изображений из набора для прямого движения и 1,3 КБ изображений из набора данных для восстановления. В общей сложности это 9,3 тыс. Изображений, чего недостаточно для обучения моей сети.

Решение, часть 1: я разделил набор данных на три разные категории: center_turns (нулевой угол при прямом движении), left_turns и right_turns.

def createTrainingDataPathsCLR(df, prefix_path):
    """
    creates training data and training labels/ measurements from a data frame
    inputs:
    df: pandas DataFrame object
    prefix_path: path to the dataset
    output: (center_turns, left_turns, right_turns) list tuple
    """    
    # Turn types
    center_turns = []
    left_turns = []
    right_turns = []
    
    
    abs_path_to_IMG = os.path.abspath(prefix_path)
    for idx, row in df.iterrows():
        center_image_cam = os.path.join(abs_path_to_IMG, row['center'].strip())
        left_image_cam = os.path.join(abs_path_to_IMG, row['left'].strip())
        right_image_cam = os.path.join(abs_path_to_IMG, row['right'].strip())
        steering_angle = row['steering']
        
        # Right image condition
        if steering_angle > 0.125:
            right_turns.append([center_image_cam, left_image_cam, right_image_cam, steering_angle])
            
        # This is a left image
        elif steering_angle < -1 * 0.125:
            left_turns.append([center_image_cam, left_image_cam, right_image_cam, steering_angle])
            
        # This is a center image
        else:
        # center images
            center_turns.append([center_image_cam, left_image_cam, right_image_cam, steering_angle])
        
    return (center_turns, left_turns, right_turns)

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

def makeMore(dataArray, amount):
    """
    This function creates additional data for center, left, and right camera angles 
    given a dataArray of a specific turn type (center_turn, left_turn, or right_turn)
    input: 
    dataArray: array of specific turn type
    amount: amount to increase the input array
    output: dataArray of same turn type with values * amount
    """
    for i in range(len(dataArray)):
        for j in range(amount):                                    
            dataArray.append([dataArray[i][0], dataArray[i][1], dataArray[i][2], dataArray[i][3]])
    return dataArray
center_turns = makeMore(center_turns, 5)
left_turns = makeMore(left_turns, 18)
right_turns = makeMore(right_turns, 12)

Теперь моя гистограмма угла поворота выглядит так:

Сделав это с обоими наборами данных, я создал гистограмму, которая выглядит следующим образом:

Предварительная обработка

Наши данные представлены в виде изображений RGB размером 160 x 320 x 3. Я использовал несколько методов предварительной обработки для увеличения, преобразования и создания большего количества данных, чтобы дать моей сети больше шансов на обобщение для различных функций трека.

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

def change_brightness(image):
    """
    Augments the brightness of the image by multiplying the saturation by a uniform random variable
    input: image (RGB)
    output: image with brightness augmentation (RGB)
    """
    bright_factor = 0.2 + np.random.uniform()
    
    hsv_image = cv2.cvtColor(image, cv2.COLOR_RGB2HSV)
    # perform brightness augmentation only on the second channel
    hsv_image[:,:,2] = hsv_image[:,:,2] * bright_factor
    
    # change back to RGB
    image_rgb = cv2.cvtColor(hsv_image, cv2.COLOR_HSV2RGB)
    return image_rgb

Переворачивание. Наши данные были получены при движении против часовой стрелки. Чтобы научить нашу модель двигаться по часовой стрелке, я позволил с вероятностью 50% вертикально перевернуть каждое изображение и инвертировать угол поворота.

def flip_image(image, ang):
    image = np.fliplr(image)
    ang = -1 * ang
    return (image, ang)

Обрезка. Нам нужен только участок дороги с полосами движения, поэтому я вырезал небо и рулевое колесо. Для изображения размером 160 x 320 x 3 пикселя я обрезал высоту с 55 до 140 пикселей. Что-то вроде этого подойдет

image = image[image.shape[0] * 0.35 : image.shape[0] * 0.875, :, :]

Изменение размера: я использовал архитектуру сети сквозного обучения Nvidia, которая использует изображения RGB размером 66 x 220 x 3, поэтому я изменил размер своих изображений на это.

img = cv2.resize(image, (220, 66), interpolation=cv2.INTER_AREA)

Конвейер полной предварительной обработки:

Генераторы:

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

def generate_training_data(data, batch_size = 32):
    """
    We create a loop through out data and 
    send out an individual row in the dataframe to preprocess_image_from_path, 
    which is then sent to preprocess_image
    inputs: 
    data: pandas DataFrame
    batch_size: batch sizes, size to make each batch
    returns a yield a batch of (image_batch, label_batch)
    """    
    image_batch = np.zeros((batch_size * 2, 66, 220, 3)) # nvidia input params
    label_batch = np.zeros((batch_size * 2))
    while True:
        for i in range(batch_size):
            idx = np.random.randint(len(data))
            row = data.iloc[[idx]].reset_index()
            x, y = preprocess_image_from_path(row['center'].values[0], row['steering'].values[0])
            
            # preprocess another center image
            x2, y2 = preprocess_image_from_path(row['center'].values[0], row['steering'].values[0])
            
            if np.random.randint(3) == 1:
                # 33% chance to overwrite center image (2) with left image + correction_factor
                x2, y2 = preprocess_image_from_path(row['left'].values[0], row['steering'] + 0.125)
                
            if np.random.randint(3) == 2:
                # 33% change to overwrite center image (2) give right image - correction_factor
                x2, y2 = preprocess_image_from_path(row['right'].values[0], row['steering'] - 0.125)
            
            
            image_batch[i] = x
            label_batch[i] = y
            
            image_batch[i + 1] = x2
            label_batch[i + 1] = y2
            
        yield shuffle(image_batch, label_batch)

В этом генераторе я создавал партии изображений. Я решил использовать партии, чтобы контролировать, что происходит с каждой партией. В этом случае я удвоил заданный размер пакета и выделил 33% для добавления изображений левой или правой камеры, а также центрального изображения. В нашем моделировании мы берем центральное изображение и прогнозируем угол поворота. Я добавил поправочный коэффициент 0,125 к изображениям левой камеры и вычел этот поправочный коэффициент из изображений правой камеры, чтобы они были в соответствии с углами поворота центральных изображений. Теперь я просто на лету создал в 2 раза больше тренировочных данных.

Я также создал генератор проверки, который каждый раз просто генерирует одно изображение. Эти изображения проходят только этапы предварительной обработки обрезки и изменения размера, такие же, как и для тестовых данных в моделировании.

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

Я решил использовать ELU (экспоненциальные линейные единицы) вместо ReLu. ELU избегает проблемы исчезающего градиента (как и ReLu и дырявый ReLu). Они также ускоряют обучение, избегая предвзятого сдвига, к которому склонны ReLu. ELU эффективны для очень глубоких моделей, в которых количество слоев превышает четыре. Эти функции активации хорошо справились с задачей ImageNet за меньшее количество эпох, чем сеть на основе ReLu с той же архитектурой. Подробнее см. Здесь.

Я реализовал слой Dropout, чтобы предотвратить чрезмерную подгонку. Я реализовал только один слой Dropout, в будущем я бы добавил больше слоев Dropout между первыми 3 свертками с вероятностями от 0,9 до 0,7, а затем включил бы вероятность 0,5 после четвертого или пятого сверточного слоя.

Я нормализовал входные значения пикселей RGB до диапазона [-1, 1]. Я уверен, что [0, 1] тоже сработало бы

Обучение:

model = nvidia_model()
train_size = len(train_data.index)
for i in range(3):
    train_generator = generate_training_data(train_data, BATCH)
    history = model.fit_generator(
            train_generator, 
            samples_per_epoch = 20480, # try putting the whole thing in here in the future
            nb_epoch = 6,
            validation_data = valid_generator,
            nb_val_samples = val_size)
    print(history)
    
    model.save_weights('model-weights-F1.h5')
    model.save('model-F1.h5')
  • Образцов на эпоху: 20480 Я использую около 80 тыс. изображений (я создал около 4/5 из них). Это масса данных. Поэтому я не отбираю все изображения для каждой эпохи. Я отбираю только 1/4 из этих 80k = ›20k изображений. Однако на каждом из этих 20k я создаю пакеты из 32 изображений, поэтому я фактически тренирую свою модель на 20480 * 32 = 655k изображениях в каждую эпоху. Это потому, что я случайным образом беру изображение в своем наборе данных, чтобы разбрасывать его по партиям с заменой. Всего обработано 20480 * 32 * 18 = 11.8M изображений, что составляет 11.8M * 66 * 220 * 3 = 513B pixels
  • Количество эпох: 18 Я использую 18 эпох за 3 цикла. Я выполнил три цикла, потому что хочу иметь возможность последовательно тренироваться достаточно долго (nb_epochs = 6), а затем иметь возможность оценить эту модель, если она хорошая, я хочу ее сохранить. Затем я обучаю еще 2 модели и выбираю модель с наименьшими потерями при проверке. Когда я попытался установить диапазон на 4, у меня возникла ошибка памяти. Итак, 3 было самым высоким, на что я мог пойти.
  • Размер пакета: 16. Затем я умножаю этот размер пакета * 2 в своем генераторе, чтобы он стал 32. Я попытался использовать большой размер пакета, но у меня очень быстро закончилась память, потому что я пытался сохранить все эти постобработанные изображения в памяти внутри функции генератора. Уменьшение размера партии до 32, похоже, сработало хорошо, и это заставило мое обучение ускориться.
  • nb_val_samples: общая длина данных проверки. Мой генератор проверки просто выдает каждый раз одно изображение, поэтому я просто использую все данные проверки для размера моей проверочной выборки. Это приводит к тому, что все мои проверочные данные загружаются в генератор один за другим в последовательном порядке. Они загрузились бы по порядку, если бы я не перетасовал обучающие данные перед разделением их на наборы данных для обучения и проверки.

Использованная литература: