Как распознать позу руки на картинке: с нуля и до рабочей модели

Підписуйтеся на Telegram-канал «DOU #tech», щоб не пропустити нові технічні статті.

Распознавание позы руки (на английском, Hand Pose Estimation) — достаточно популярная задача в компьютерном зрении, часто используется в проектах связанных с виртуальной реальностью. В открытом доступе — сотни пейперов, но практически нет базовых вводных туториалов. Пришло время это исправить.

В этом туториале мы разберём основы теории для распознавания 2D-позы руки. Это базовый план. А если есть желание ещё и самому натренировать модель на PyTorch — обратите внимание на секции «Практика».

Будет полезно ML инженерам, разработчикам и студентам, которые уже сталкивались с задачами компьютерного зрения. Буду рассказывать максимально просто, но ожидаю, что хотя бы базовый опыт с конволюционными нейронными сетями у вас есть. Если с PyTorch вы раньше не работали или работали мало — не проблема, этот пост получился ещё и вводным в PyTorch.

Туториал делаю на основе своей дипломной работы в Украинском католическом университете, перевожу те 40 страниц во что-то менее научное и более практичное и понятное.

Кстати, я веду блог о Deep Learning — Not Rocket Science. Пишу туториалы, делюсь лайфхаками и рассказываю про свои рабоче проекты (насколько это разрешает NDA :). Подписывайтесь, здесь говорим просто о сложном.

Поехали!

Содержание

Что же именно мы будем распознавать?

Поза руки определяется расположением ключевых точек (keypoint locations). То есть под «распознать позу руки» имеется в виду «найти координаты ключевых точек руки». У руки есть 21 ключевая точка (смотрите схему внизу): запястье плюс 5 пальцев * 4 точки на палец. И зная расположение каждой из точек (на картинке и относительно друг друга), мы однозначно можем определить, какой жест показывает рука — «лайк», «окей» или «пис».

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

Изображение 1. Ключевые точки руки. Порядок имеет значение

Типичная модель для распознавания 2D-позы руки выглядит так:

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

Изображение 2. Типичная модель для распознавания 2D-позы руки

И несколько моментов.

Что делать со второй рукой? Как правило, модель тренируют распознавать только правые руки (можно и только левые, конечно). А чтобы модель предсказывала позу левых рук, ей на вход подают картинки в зеркальном отражении, которые выглядят как... правые руки. Ну и если вы работаете с изображениями людей, вам нужна будет ещё и модель, которая классифицирует руки на правые / левые.

Часто в пейперах и демо позу руки показывают как скелет с разноцветными пальцами. Поначалу это сильно путает. Но. Скелет — это всего лишь визуализация (очень удобная, кстати), а поза руки всё равно представлена как координаты ключевых точек. И зная координаты каждой точки, мы без проблем можем нарисовать скелет, правда?

Изображение 3. Слева: как визуализируют позу руки. Справа: как распознают позу руки

Задача распознавания позы руки — очень широкая, и за один туториал всё не покрыть, поэтому план на сегодня: учимся распознавать 2D-позу руки с одной RGB-картинки. То есть на вход — одна RGB картинка, на выход — координаты ключевых точек на этой картинке.

Ещё можно распознавать 3D-позу руки (например, в координатной системе камеры), и тренировать модель на Depth-картинках, RGBD картинках и стерео парах. И как отдельная задача — трекинг, распознавание позы руки непрерывно на видеоряде. Если вы в будущем планируете работать с чем-то таким, этот туториал вам тоже поможет.

Где найти датасет?

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

  • Реальные и синтетические данные.
  • Фотографии и видео.
  • Depth, RGB, RGBD картинки и стерео пары.
  • Картинки от третьего или первого лица.
  • Формат разметки: 2D-координаты, 3D-координаты, маска.
  • Количество размеченных ключевых точек.
  • Все ли ключевые точки присутствуют на изображениях, отмечены ли скрытые (occluded) точки, есть ли изображения, где рука взаимодействует с объектами...

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

Этот туториал я постаралась сделать удобным для всех.

  • Например, для тех, кому интересно в общих чертах ознакомиться с задачей распознавания позы руки, и тех, кто не хочет углубляться в код. Если это про вас, читайте только основные секции и игнорируйте секции «Практика».
  • Если же вы сегодня настроены тренировать свою собственную модель на PyTorch или разобрать код проекта, который я подготовила для этого туториала, — об этом всём будет в частях «Практика».

Практика. Разбираемся с датасетом

Для этого туториала мы будем использовать открытый датасет FreiHAND, который можно скачать здесь. В этом датасете 33 тыс. фотографий правых рук, есть 2D-координаты для 21 ключевой точки (точки размечены в порядке, как и на Изображении 1).

Очень рекомендую на этом моменте остановиться, скачать датасет, прочитать описание, пройтись по папкам и файлам — в общем, изучить и привыкнуть к данным, с которыми вы будете работать.

Изображение 4. Структура папок датасета FreiHAND

В датасете FreiHAND нам понадобится:

  • Папка training/rgb/ — здесь, собственно, фотографии рук. Для простоты будем использовать только первые 32 560 изображений — это сырые и уникальные изображения. Остальные файлы в папке — это аугментированные копии этих 33 тыс. изображений.
  • Файлы training/training_xyz.json и training/training_K.json — 3D-координаты ключевых точек и матрица перевода 3D-координат в 2D (camera matrix), соответственно. В большинстве датасетов для распознавания позы руки — 2D-координаты вам придётся считать самостоятельно. Где-то в документации или гитхаб-проекте авторы оставляют нужную формулу.

И вот что я нашла на гитхабе FreiHAND:

def projectPoints(xyz, K):
    xyz = np.array(xyz)
    K = np.array(K)
    uv = np.matmul(K, xyz.T).T
    return uv[:, :2] / uv[:, -1:]

​​Теперь можно визуализировать, для этого я писала уже свою функцию:

Изображение 5. Случайные изображения с FreiHAND датасета

Как подготовить датасет к тренировке модели?

Некоторые шаги вам уже будут знакомы из других задач компьютерного зрения.

1. Разделите датасет на тренировочную, валидационную и тестовую части. Как обычно, на тренировочной части мы тренируем модель, с помощью валидационной выбираем, когда остановить тренировку, чтобы не переобучиться, а тестовая часть — для оценки точности модели.

2. Измените размер изображений до 128×128. Рука — относительно простой объект, так что такого маленького размера будет достаточно. Не забудьте «изменить размер» для координат ключевых точек. Пример ниже.

Изображение 6. Если меняете размер изображения, соответственно, меняйте и координаты ключевых точек

3. Изначально значения картинки в интервале [0,255]. Переведите в формат [0,1].

4. Нормализируйте изображения, используя средние и стандартные отклонения, рассчитанные на тренировочном сете. Для каждого цветового канала (R, G, B) будет отдельное среднее и отдельное стандартное отклонение. Средние и стандартные отклонения считаются по всем пикселям цветового канала во всех изображениям.

5. Создайте хитмапы (heatmaps, тепловые карты) на основе координат ключевых точек. Хитмапы — это очень популярный подход для распознавания 2D-позы руки и позы человека (Human Pose Estimation). Хитмапы буквально есть в каждом пейпере, может, с небольшими модификациями.

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

Изображение 7. Как создать хитмапу на основе координаты ключевой точки

Хитмапы нужно размывать (с английского — blur), чтобы предотвратить переобучение модели и сделать процесс тренировки стабильнее и быстрее. Параметры размытия не так важны, здесь работает правило «на глаз»: точка на хитмапе не должна быть ни очень большой, ни очень маленькой. Нормализация Min-Max нужна, потому что в финальном слое нейронной сети мы будем использовать сигмоиду.

Итого:

  • Х — изображение размера 3×128×128.
  • Y — массив размера 21×128×128, состоящий из последовательно сложенных хитмап. У этих хитмап тоже есть порядок: первая хитмапа отвечает за расположение запястья, последняя — кончика мизинца (как и на Изображении 1).

Практика. Готовим данные к тренировке

Разделим датасет на тренировочную, валидационную и тестовую части, как на схеме внизу. FreiHAND датасет выглядит перемешанным, поэтому делить будем по индексам (индексы в названии файлов).

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

Изображение 8

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

В PyTorch есть два класса, которые мы будем использовать.

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

train_dataloader = DataLoader(
    dataset=train_dataset, #об этом будет чуть дальше
    batch_size=48, 
    shuffle=True, 
    drop_last=True, 
    num_workers=2
)

Теперь можно циклом проходиться по даталодеру и получать батчи данных:

for data_batch in train_dataloader:
    #do something

Dataset. В этом классе прописывается логика, где именно брать изображения и разметку и как их предварительно обрабатывать. У PyTorch есть несколько уже написанных датасетов, но настраивайтесь, в большинстве случаем Dataset вам придётся писать самостоятельно. Как и в этот раз.

Это не сложно, стоит следовать всего трём правилам:

  1. Наследовать класс torch.utils.data.Dataset.
  2. Написать метод __len__(), который будет отдавать размер датасета.
  3. Написать метод __getitem__(), который принимает id изображения (sample), и отдаёт изображения и разметку (labels). Позже изображения и разметка будут собираться объектом dataloader в батчи.

Итак, класс Dataset для FreiHAND должен выглядеть приблизительно так (полная версия здесь):

from torch.utils.data import Dataset

class FreiHAND(Dataset):
    def __init__(self, config, set_type="train"):
        ## initialize path to image folders
	    ## initialize paths to files with labels
	    ## create train/test/val split
	    ## define data augmentations
         
    def __len__(self):
	   return len(self.anno)

    def __getitem__(self, idx):
        ## load image by id, use PIL librabry
        ## load its labels
        ## do augmentations if needed
        ## convert everything into PyTorch Tensors

        return {
            "image": image,
            "keypoints": keypoints,
            "heatmaps": heatmaps,
            "image_name": image_name,
            "image_raw": image_raw,
        }

И да, не забудьте рассчитать среднее и стандартное отклонение по RGB каналам перед тем, как создавать объекты класса Dataset и DataLoader. Здесь функция для расчета. Рассчитанные параметры нужно добавить в Normalize() вот так. И только теперь создавайте объекты классов Dataset и DataLoader.

Нужно написать код только одного класса Dataset. Этот код будет использоваться, чтобы создать 3 объекта класса Dataset — для тренировочного, валидационного и тестового сетов. Логику для каждого сета можно задать через if-else в коде класса и через аргумент set_type. Пример ниже:

train_dataset = FreiHAND(config=config, set_type="train")
train_dataloader = DataLoader(
    train_dataset, 
    config["batch_size"], 
    shuffle=True, 
    drop_last=True, 
    num_workers=2
)

Если c PyTorch вы раньше не работали, просмотрите ещё и мой ноутбук для тренировки модели, секцию Data, чтобы лучше разобраться с даталодерами.

Архитектура нейронной сети

Нам нужна архитектура формата encoder-decoder, потому что вход и выход модели одного и того же размера — 128×128. Можно взять, например, U-Net. Нам не нужна такая глубокая архитектура, как в оригинальной статье, потому что рука (повторяюсь) — простой объект.

Предлагаю использовать что-то вот такое:

Изображение 9. Моя упрощённая архитектура U-Net

Практика. Собираем нейронную сеть

В PyTorch есть уже собранные и натренированные модели (здесь список), но, к сожалению, там нет архитектуры U-Net. Да и для учебных целей давайте писать свою модель.

Модели в PyTorch собираются так:

  • Наследуем от класса torch.nn.Module.
  • Переписываем функцию forward(), где расписыаем логику прохода вперёд (forward pass).

В архитектуре U-Net есть соединения с пропуском слоёв (skip connections), поэтому выходы некоторых слоёв нужно будет сохранять и использовать позже. Градиенты в PyTorch рассчитываются автоматически на основе логики forward().

Ниже пример шаблона, как пишут модели в PyTorch. А полная версия модели со схемы выше вот здесь.

class ShallowUNet(nn.Module):
    def __init__(self, in_channel, out_channel):
        super().__init__()
	   # initialize layer - custom or from PyTorch list

    def forward(self, x):
	   # implement forward pass
	   # you can do literally anything here
	   return out

Подробнее о тренировке

Большинство пейперов используют MSE Loss для хитмап, например вот эти две известные работы по распознавания 2D-позы человека — один и два. Я не представляю, как они натренировали модель с MSE Loss’ом, у меня не вышло повторить.

А потом коллега посоветовал попробовать IoU Loss. Этот лос используется в задачах обнаружения объектов (Object Detection), а если немного адаптировать — можно и для сегментации использовать (пример в этом пейпере). Хитмапы похожи на сегментационные маски, так что лос считаем по формулам ниже. И с таким лосом модель уже тренируется хорошо.

Изображение 10. Как считать IoU Loss для хитмап (yi — предсказания, ti — фактические значения пикселей в хитмапах). Лос считается сначала для каждой хитмапы отдельно, потом усредняется по 21 хитмапе, а потом усредняется по всем изображениям в батче

Практика. Тренируем модель

Оставляю полный код класса Trainer. Это код я писала сама, собирая идеи по туториалам PyTorch. Код для тренировки, вплоть до шага с обновлением градиентов, вам нужно писать самостоятельно. С одной стороны — неудобно до ужаса, но с другой — даёт больший контроль и понимание, что происходит. Кстати, вы не обязаны заворачивать код для тренировки именно в класс, можете писать функциями или вообще строками в ячейке ноутбука.

Рассказываю, на что обратить внимание, когда пишите код для тренировки.

  1. В пределах каждой эпохи делайте стадии тренировки и оценки (evaluation). Перед стадией тренировки нужно вручную переключить модель в состояние «тренировка» — командой model.train(), а перед оценкой (или предсказаниями) в состояние «оценка» — model.eval(). Это нужно, потому что некоторые слои ведут себя по-разному во время тренировки и предсказаний, например Dropout и BatchNorm.
  2. Во время оценки (и предсказаний) хорошей практикой будет использовать команду torch.no_grad(). Это скажет модели «Не считай градиенты сейчас», и код будет работать быстрее и использовать меньше памяти.
  3. Если вы планируете тренировать на GPU. По правилам PyTorch данные и модель должны быть на одном девайсе. По умолчанию, данные и модель создаются на CPU, и их нужно будет перенести на GPU. Разметку (labels) тоже, иначе вы не сможете посчитать лос, ведь предсказания модели будут на GPU. Пример:

    device = torch.device("cuda" if torch.cuda.is_available() else "cpu")
    model = model.to(device)
    inputs = data["image"].to(device)
    labels = data["heatmaps"].to(device)
    

    PyTorch позволяет тренировать модель на GPU, а предсказывать на CPU. Нужно будет просто перенести модель на нужный девайс, вот так:

    device = torch.device("cuda" if torch.cuda.is_available() else "cpu")
    model.load_state_dict(
        torch.load(model_path, map_location=device))
    )
    
  4. Loss в PyTorch или выбираете со списка. Или пишете свой, по аналогии с моделью. Буквально по аналогии — так же наследуете класс torch.nn.Module и переписываете функцию forward().

    Шаблон ниже, а полный код IoU Loss вот здесь.

    class IoULoss(nn.Module):
        def __init__(self):
            super(IoULoss, self).__init__()
            #initialize parameters
     
        def forward(self, y_pred, y_true):
            #calculate loss from labels and predicitons
            return loss
    

С кодом закончили, теперь запускаем! Полный код для тренировки я собрала в этом ноутбуке.

Выбираем размер батча, который помещается в памяти (я брала 48), и количество батчей за эпоху «на глаз» (я брала 50). Начальную скорость обучения (learning rate) советую ставить 0,1 и снижать каждый раз, когда тренировочный лос перестаёт снижаться. Останавливаем тренировку, если валидационный лос не снижается на протяжении, например, 10 эпох. Тренировать лучше на GPU, если такая возможность есть.

С моими параметрами тренировки и на GPU модель натренировалась за 2 часа и где-то 200 эпох. Финальный лос на тренировочном и валидационном сете — 0,437 и 0,476, соответственно. Цифры ориентировочные, даже если вы перетренируете модель с теми же параметрами — результат будет немного другим.

Разбираемся с предсказаниями

Итак, мы натренировали модель, которая отдаёт на выход хитмапы. Но хитмапы — это ещё не координаты, так что нужна дополнительная постобработка.

Напомню, нам нужно для каждой ключевой точки руки получить (x, y) координаты, то есть расположение ключевой точки на изображении руки. Координаты (x, y) будут либо в интервале [0, размер_картинки], либо в интервале [0,1] — тут уже как вам нравится.

Посмотрите на хитмапы ниже, так выглядят предсказания модели. Вы легко можете определить координаты ключевой точки при взгляде на хитмапу, правда? Ключевая точка руки расположена где-то в центре «белой точки» на хитмапе. Ту же логику мы и используем в постобработке.

Изображение 11. Предсказания модели для случайного изображения руки

Варианта два:

  1. Простой. Найти на хитмапе пиксель с наибольшим значением. Взять его (x, y) координаты. Это и будут координаты ключевой точки руки.
  2. Сложный, но надёжный. Рассчитать средневзвешенные х и y по всем пикселям хитмапы. Инструкция ниже.

Изображение 12. Рассчитываем координаты ключевой точки с хитмапы

Практика. Превращаем хитмапы в координаты

Если вы хотите идти простым путём, найти расположение пикселя с наибольшим значением можно функцией numpy.argmax(). А если сложным — вот функция, как я считала средневзвешенное значения по хитмапам.

Оцениваем точность модели

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

Ниже пример, в каком формате визуально просматривать предсказания. Можно для случайных изображений отобразить фактические координаты ключевых точек и рядом — предсказания модели (уже после постобработки). Позу руки удобно визуализировать через скелет с разноцветными пальцами.

Изображение 13. Визуализация предсказаний модели на тестовом сете

Самая главная метрика, которую важно рассчитать, — это средняя ошибка (error) для ключевой точки по всему тестовому сету.

Сначала считаем ошибку для каждой ключевой точки отдельно — как евклидово расстояние между фактическими и предсказанными (x, y) координатами. Координаты можно брать в пикселях — в интервале [0, размер_картинки], или в процентах от картинки — в интервале [0,1]. Усредняем ошибку по всем ключевым точкам на изображении и по всем изображениям в датасете.

Изображение 14. Как считают ошибку в задаче распознавания 2D-позы руки

Можно пойти дальше и сообщить медиану и проценты по ошибкам. И рассчитать среднюю ошибку в датасете по каждой из 21 ключевой точки, или среднюю ошибку по пальцам или по суставам, как иногда делают в пейперах.

Практика. Оцениваем точность модели

Код для визуализации и оценки точности в ноутбуке для предсказаний.

Я натренировала модель, как и расписывала в этом туториале, и получила такую точность: средняя ошибка для ключевой точки на тестовом сете — 4,5% размера изображения, или 6 пикселей для картинок 128×128, или 8 пикселей для картинок 224×224.

Что дальше?

Я не хотела усложнять это туториал, поэтому остановилась на простой модели. Она работает, но это далеко не та точность, которая нужна для продакшена.

Если вдруг вы планируете дорабатывать эту модель, оставлю пару идей. Больше данных, аугментация изображений, глубже архитектура и Transfer Learning. И ещё, скорее всего, придется читать пейперы — полный список по распознаванию позы руки собран здесь.

Поздравляю, что дошли до конца.

👍НравитсяПонравилось17
В избранноеВ избранном9
LinkedIn
Допустимые теги: blockquote, a, pre, code, ul, ol, li, b, i, del.
Ctrl + Enter
Допустимые теги: blockquote, a, pre, code, ul, ol, li, b, i, del.
Ctrl + Enter

Для определения типа жеста, переводим точки в зависимость друг от друга как в ShapeContext и передаем на класификатор

Определять тип жеста лучше через обычный классификатор изображений. А за Hand Pose Estimation браться только если в бизнес задаче требуется найти координаты ключевых точек руки.

Ошибка больше будет. Через граф можно определить явную ошибку жеста, через класификатор картинки нет.

Разумеется вопрос зачем это надо. Если плеер запускать то впринципе пох как.

С моими параметрами тренировки и на GPU модель натренировалась за 2 часа и где-то 200 эпох. Финальный лос на тренировочном и валидационном сете — 0,437 и 0,476, соответственно.

Круто. А какой GPU использовался?

Спасибо.
Тренировала в Google Notebooks (платная штука, часть Google AI Platform), там добавила себе NVIDIA Tesla T4.

Открою секрет, как распознают люди: они не опознают ладонь. Они опознают конечность. Длинное и сверху = рука. Например, вот это — рука. И это рука. А это — не рука на первый взгляд. И это нет. А это да.

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

Если же это хардварная разработка, так и вовсе поставьте инфракрасную камеру, совсем просто-быстро будет. В том числе на очень низкое разрешение, даже на 0.3 мегапикселя сойдёт.

Ви сейчас говорите о задаче детекции рук (Object Detection) или вообще классификации. Моя же статья — о распознавании позы руки, и это абсолютно другая задача.

В том-то и дело, что нет. Вы пытаетесь опознавать в уютных условиях зелёного фона. Реальные условия будут сильно отличаться. Потому, вместо того чтобы искать ключевые точки, нужно брать параметры самого объекта (большого), и уже от них плясать. Тогда вам не нужно будет искать ключевую точку, вы просто предположите, где она.

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

PS. Порылись бы в опенсорсах и прочих халявах, может кто уже пытался язык глухих переводить, могуть быть готовые наработки, которые проще купить, чем самими изобретать велосипед. Заодно бы и подобный продукт выпустили — для раскрутки репутации.

Подписаться на комментарии