Перекрестная ссылка из моего личного блога: https://rcd.ai/eye-tracking-nn-and-loss/ Читайте с красивой подсветкой синтаксиса!

Задний план

Во время учебы в аспирантуре Университета Реджис у меня была возможность начать изучать свой собственный проект во время моего курса глубокого обучения. (Источник данных) Используя этот набор данных перед своим практическим опытом (весна 2019 г.), я надеюсь не только изучить и смоделировать некоторые интересные данные, но и быть готовым к решению проблемы отслеживания взгляда путем объединения известных решений, а также текущих исследований в Глубокое обучение (например, обучение синтетическим данным, JavaScript Convnets, адаптация предварительно обученной модели) и, таким образом, привносит некоторые концепции, представленные Krafka et al. (2016) в Интернет.

Репозиторий: https://github.com/rcdilorenzo/msds-686-eye-tracking

Начать, затем вернуться

Обнаружив проект выравнивания лиц несколько недель назад, я запустил его для всех 2,4 млн изображений, доступных в наборе данных GazeCapture. Ниже приведен образец вывода глубокой нейронной сети Адриана Булата (написанной с использованием PyTorch).

В моих целях на этой неделе я сосредоточился на моделировании прогнозируемого расстояния (x, y) от объектива камеры на основе выходных данных лицевых ориентиров (68 точек в трехмерном пространстве), а также вырезанных изображений каждого глаза. После долгой отладки в моих попытках заставить размерные формы слоев keras взаимодействовать, я обнаружил, что могу комбинировать изображения для левого и правого глаза только с точками (x, y, z) библиотеки выравнивания лиц, имея предопределенная форма ввода для каждого глаза. С этой целью я потратил некоторое время на поиск с возвратом, чтобы получить согласованное изображение 128x128 пикселей (как показано ниже).

Хотя я основывал каждое изображение глаза на координатах из библиотеки выравнивания лиц, мне пришлось немного изменить размер, чтобы убедиться, что у меня есть постоянный размер ввода. Выполнение этого изменения размера с помощью tensorflow оказалось несложным делом. Проблема в первую очередь возникла с изображениями, на которых проецируемые ориентиры лица указывали на то, что по крайней мере один из глаз даже не был виден на изображении. Это означало, что мне все еще нужно было создавать изображение 128x128, поскольку моя нейронная сеть ожидала бы это в качестве входных данных. Поэтому для кода тензорного потока мне требовалось, чтобы изображение размером не менее 1 x 1 пиксель было вырезано из изображения на основе координат перед масштабированием до соответствующего размера.

К счастью, я смог немного отступить и переделать подготовку ввода, чтобы сеть могла надежно зависеть только от изображений 128x128 для правого и левого глаза. Ниже приведен код тензорного потока для извлечения одного глаза из изображения и точек ориентира.

def eye_tensor(image, predictions, factor, index = LEFT):
    eye_points = predictions[index:(index + 6), :]
    x = eye_points[:, 0]
    y = eye_points[:, 1]

    # Image dimensions
    image_shape = tf.shape(image)
    image_height = image_shape[0]
    image_width = image_shape[1]

    # Find bounding box
    min_x_raw, max_x_raw = tf.reduce_min(x), tf.reduce_max(x)
    min_y_raw, max_y_raw = tf.reduce_min(y), tf.reduce_max(y)

    # Expand by factor and reform as square
    width = tf.to_float(max_x_raw - min_x_raw)
    height = tf.to_float(max_y_raw - min_y_raw)
    sq_size = tf.to_int32(tf.round(tf.reduce_max([
        width * factor,
        height * factor
    ])))

    # Compute deltas
    width_delta = tf.to_int32(tf.round((tf.to_float(sq_size) - width) / 2))
    height_delta = tf.to_int32(tf.round((tf.to_float(sq_size) - height) / 2))

    # Pre-compute max_x and max_y
    max_x = max_x_raw + width_delta
    max_y = max_y_raw + height_delta

    # Calculate whether eye visible within image
    both_eyes_visible = tf.logical_and(max_x < image_width, max_y < image_height)

    # Update frame based on delta (but with min/max boundaries)
    max_x = tf.reduce_min([
        tf.reduce_max([max_x, 1]),
        tf.shape(image)[1]
    ])
    min_x = tf.reduce_max([
         tf.reduce_min([min_x_raw - width_delta, max_x - 1]), 0
    ])
    max_y = tf.reduce_min([
        tf.reduce_max([max_y, 1]),
        tf.shape(image)[0]
    ])
    min_y = tf.reduce_max([
        tf.reduce_min([min_y_raw - height_delta, max_y - 1]), 0
    ])

    # Create image and scale to (128, 128)
    unscaled_shape = tf.stack([
        max_y - min_y,
        max_x - min_x,
        tf.constant(3)
    ])
    eye = tf.reshape(image[min_y:max_y, min_x:max_x] / 255, unscaled_shape)
    scaled_eye = tf.image.resize_images(eye, IMAGE_SIZE)

    # Return original bounding box and resized image
    return (scaled_eye, (min_x, max_x, min_y, max_y), both_eyes_visible)

Для тех, кто раньше не видел этот тип кода тензорного потока, он, по сути, определяет операцию, выполняемую над каждым вводом. Ознакомьтесь с моими ГП или ЦП для глубокого обучения? сообщение о том, почему это огромное вычислительное преимущество.

Проблема «невидимого глаза»

Несмотря на то, что входное изображение в сеть могло быть «мусором» (или расширенным изображением размером 1x1 пиксель), технически в сети все еще была точка, на которую якобы смотрит человек. Когда я начал перебирать различные типы архитектуры, становилось все более ясно, как сети действительно нужно «знать», действительно ли она должна предсказывать точку или даже не основываясь только на изображении.

Как этого добиться? По сути, требовался дополнительный вероятностный вывод, чтобы сообщить нейронной сети, действительно ли ее регрессивный вывод (x, y) ценен. Итак, резюмируя, вот желаемые входы и выходы для сети:

Входные данные: левый глаз (128 x 128), правый глаз (128 x 128), трехмерные ориентиры (68 точек)

Выходные данные: координаты взгляда (x и y относительно линзы), вероятность взгляда (вероятность от 0 до 1)

Как видно из приведенного выше кода тензорного потока, это значение правдоподобия не так уж сложно получить из координат. Однако мне нужно было проверить код на правильность, поэтому я сделал это в стиле TDD, используя следующие тесты (test_generator.py).

BOTH_EYES_IDX = 0
ONE_EYE_IDX   = 1

# Load sample data frame and add landmarks as column
df = sample_df()
df['Landmarks'] = np.load('02-facial-landmarks/sample_landmarks.npy')

generator = InspectNNGenerator(session, df, 2, set_type=SET_TYPE_TRAIN)

# Remove randomization
generator.data_frame = df

inputs, outputs = generator.__getitem__(0)

def test_input_size():
    assert len(inputs) == 3

def test_input_row_counts_match():
    assert list(map(len, inputs)) == [2, 2, 2]

def test_eye_inputs():
    left_eyes = inputs[0]
    right_eyes = inputs[1]

    assert left_eyes[BOTH_EYES_IDX].shape == (128, 128, 3)
    assert left_eyes[ONE_EYE_IDX].shape == (128, 128, 3)

    assert right_eyes[BOTH_EYES_IDX].shape == (128, 128, 3)
    assert right_eyes[ONE_EYE_IDX].shape == (128, 128, 3)

def test_landmark_inputs():
    landmarks = inputs[2]

    assert landmarks.shape == (2, 68, 3)

def test_output_shape():
    assert outputs.shape == (2, 3)

def test_gaze_likelihood_output():
    # 1 = both eyes visible
    # 0 = one or both eyes missing
    assert outputs[BOTH_EYES_IDX, 2] == 1.0
    assert outputs[ONE_EYE_IDX, 2] == 0.0

Написание функции потерь

Итак, вот в чем проблема. Сгенерировать вероятность в виде значения от 0 до 1 не так уж и сложно. Более сложная часть - это единственная функция потерь, которая описывает, насколько на самом деле неверно предсказание сети. До этого момента я использовал среднеквадратичную ошибку (MSE) расстояния между предсказанными и фактическими точками (также известное как евклидово расстояние или теорема Пифагора).

(Не стесняйтесь переходить к следующему разделу, математика - не ваша чашка чая; вы многое не пропустите.)

Концептуально цель состоит в том, чтобы наказать это евклидово расстояние коэффициентом, который описывает разницу в предсказанной и фактической вероятности того, что координата действительно значима. Если сеть предсказывает, что координата имеет значение, но правда в том, что один глаз не виден, потери должны быть описаны как евклидово расстояние, умноженное на коэффициент больше 1. Другое требование этой функции потерь состоит в том, что необработанное евклидово расстояние должно будет возвращен, если результат вероятности совершенно правильный. Собирая эти требования вместе, можно определить единое уравнение, объединив дельту вероятности между 1 и 2 (коэффициент) и существующую функцию потерь. Вот один из способов сделать это без операции с абсолютным значением.

Кроме того, вот реализация тензорного потока (которая, по иронии судьбы, выглядит немного чище):

# (1/b) ∑^b_(i=1)([(^G-G)^2 + 1]^2 * [(^y - y)^2 + (^x - x)^2])
def loss_func(actual, pred):
    x_diff = tf.square(pred[:, 0] - actual[:, 0])
    y_diff = tf.square(pred[:, 1] - actual[:, 1])
    g_diff = tf.square(pred[:, 2] - actual[:, 2])

    return K.mean(tf.square(g_diff + 1) * (y_diff + x_diff))

Проверьте эти функции активации

Имея функцию потерь, я смог продолжить эксперименты с более простыми версиями проверенных проектов нейронных сетей на основе изображений, таких как Inception и ResNet50. К сожалению, для многих итераций, даже до добавления третьего вывода, я не мог добиться, чтобы потери опустились ниже 28, но упорно колебались в районе 28–33. Наконец, когда я начал добавлять третий выход, я понял, что у меня есть функция активации sigmoid для координаты (x, y). Для тех, кто не знаком с этой функцией сразу, вот ее график:

Проблема в том, что оно никогда не может быть меньше 0 или больше 1! Чувствуя себя униженным после этой глупой ошибки, я быстро настроил функции для использования linear функций активации, поскольку координата (x, y) технически не ограничена каким-либо конкретным диапазоном. (Хотя я мог произвольно ограничить его до самого большого устройства iOS в этом случае, поскольку это единственные устройства из GazeCapture, я предпочел оставить нейронную сеть более общей.)

Строй, измеряй, учись

На протяжении всего этого процесса модификации вывода и адаптации потерь я работал над различными архитектурами. Хотя еще слишком рано определять, потребуется ли настройка обсуждаемой функции потерь, текущие сеансы обучения модели имели успех, хотя и ограниченный. Многие из архитектурных итераций мне удалось быстро отбросить, поскольку функция потерь приводила к значениям в триллионы или квадриллионы (даже с изменением масштаба нового выхода потерь, который намного больше, чем 28–33).

Имея в своем распоряжении два графических процессора (Nvidia 1080 Ti) в моей пользовательской сборке, я в настоящее время использую два небольших варианта архитектуры, измеряя результаты с помощью TensorBoard, а также контролируя вывод. Я все еще не уверен в некоторых результатах, но продолжаю отлаживать экстремальные колебания, которые, кажется, происходят в некоторых выходных данных loss и val_loss. Поскольку во многих предыдущих итерациях было больше 10 ^ 24 значений, я продолжаю использовать эти модели в течение полного цикла в 100 эпох, чтобы увидеть, сходятся ли они в конечном итоге.

Хотя эти результаты могут показаться неутешительными, некоторые характеристики, в частности, v6 (обозначены красным) немного более интересны. Каждую эпоху каждая модель использует весь доступный обучающий набор, а также заранее определенный набор проверки, установленный исходными исследователями GazeCapture (где были обнаружены лицевые ориентиры).

Хотя результат v6, кажется, сильно варьируется в зависимости от эпохи, наблюдение за результатами лично представляет другую историю. Величина потерь неуклонно снижается, возможно, в 95% случаев, а затем резко возрастает на несколько порядков в оставшиеся 5% времени. Представление TensorBoard фиксирует только потери в конце каждой эпохи, что происходит только каждые ~ 1,5 миллиона строк (плюс набор проверки). Другое соображение, которое необходимо сделать, заключается в том, что большинство функций активации должны использовать линейную функцию активации и, следовательно, не ограничивают выход каждого нейрона. Мне просто придется сидеть и ждать, чтобы увидеть, как продвигается модель, особенно учитывая, что вся модель сейчас составляет всего 467 КБ.

left_eye_input = Input(shape=(128,128,3))
right_eye_input = Input(shape=(128,128,3))
landmark_input = Input(shape=(68,3))

def eye_path(input_layer, prefix='na'):
    return pipe(
        input_layer,
        Conv2D(8, (3, 3), activation='relu', padding='same',
               name=(prefix + '_3x3conv1')),
        MaxPooling2D(pool_size=(3, 3), padding='same',
                     name=(prefix + '_max1')),
        Conv2D(8, (3, 3), activation='relu', padding='same',
               name=(prefix + '_3x3conv2')),
        MaxPooling2D(pool_size=(3, 3), padding='same',
                     name=(prefix + '_max2')),
        Conv2D(4, (2, 2), activation='relu', padding='same',
               name=(prefix + '_2x2conv1')),
        MaxPooling2D(pool_size=(2, 2), padding='same',
                     name=(prefix + '_max3')),
        BatchNormalization(),
        Flatten(name=(prefix + '_flttn'))
    )


left_path = eye_path(left_eye_input, prefix='left')
right_path = eye_path(right_eye_input, prefix='right')

landmarks = pipe(
    landmark_input,
    Dense(8, activation='relu'),
    Flatten()
)

grouped = concatenate([left_path, right_path, landmarks])

coordinate = pipe(
    grouped,
    Dense(8, activation='linear'),
    Dense(2, activation='linear', name='coord_output')
)

gaze_likelihood = pipe(
    grouped,
    Dense(8, activation='relu'),
    Dense(1, activation='sigmoid', name='gaze_likelihood')
)

output = concatenate([coordinate, gaze_likelihood])

model = Model(inputs=[left_eye_input, right_eye_input, landmark_input],
              outputs=[output])

Мысли или вопросы? Что-то не объяснили хорошо? Я только начинаю в этом мире глубокого обучения и хотел бы услышать, что вы хотите сказать.