- PVSM.RU - https://www.pvsm.ru -
Около года назад компания Intel Movidius [1] выпустила устройство для эффективного инференса сверточных нейросетей — Movidius Neural Compute Stick (NCS). Это устройство позволяет использовать нейросети для распознавания или детектирования объектов в условиях ограниченного энергопотребления, в том числе в задачах робототехники. NCS имеет USB-интерфейс и потребляет не более 1 ватта. В этой статье я расскажу об опыте использования NCS с Raspberry Pi для задачи обнаружения лиц в видео, включая как обучение Mobilenet-SSD детектора, так и его запуск на Raspberry.
Весь код можно найти в моих двух репозиториях: обучение детектора [2] и демо с обнаружением лиц [3].
В своей первой статье [4] я уже писал про обнаружение лиц с помощью NCS: тогда речь шла о YOLOv2 [5] детекторе, который я конвертировал из формата Darknet [6] в формат Caffe [7], а затем запускал на NCS. Процесс конвертирования оказался нетривиальным: поскольку эти два формата по-разному задают последний слой детектора, выход нейросети приходилось парсить отдельно, на CPU, с помощью куска кода из Darknet. Кроме того, этот детектор не удовлетворил меня как по скорости (до 5.1 FPS на моем ноутбуке), так и по точности — позже я убедился, что из-за чувствительности к качеству изображения от него сложно получить хороший результат на Raspberry Pi.
В итоге я решил просто обучить свой детектор. Выбор пал на SSD [8] детектор с Mobilenet [9] энкодером: легковесные свертки Mobilenet позволяют добиться высокой скорости без особых потерь в качестве, а сам SSD детектор не уступает YOLO и работает на NCS из коробки.
SSD (Single Shot Detector) работает так: на выходы нескольких сверток энкодера навешиваются по два сверточных слоя: один предсказывает вероятности классов, другой — координаты ограничивающих рамок. Есть еще третий слой, который выдает координаты и положения дефолтных рамок на текущем уровне. Смысл такой: выход любого слоя естественным образом разбит на ячейки; ближе к концу нейросети их становится все меньше (в данном случае, из-за сверток с stride=2
), а поле видимости каждой ячейки увеличивается. Для каждой ячейки на каждом из нескольких выбранных слоев мы задаем несколько дефолтных рамок разного размера и с разными соотношениями сторон, а дополнительные сверточные слои используем, чтобы поправить координаты и предсказать вероятности классов для каждой такой рамки. Поэтому SSD детектор (так же, как и YOLO) всегда рассматривает одинаковое число рамок. Один и тот же объект может детектироваться на разных слоях: во время обучения сигнал посылается всем рамкам, которые достаточно сильно пересекаются с объектом, а во время применения детекции объединяются с помощью non maximum suppression (NMS). Финальный слой объединяет детекции со всех слоев, считает их полные координаты, отсекает по порогу вероятности и производит NMS.
Код для обучения детектора расположен здесь [2].
Я решил воспользоваться готовым Mobilenet-SSD детектором [12], обученным на PASCAL VOC0712 [13], и дообучить его на обнаружение лиц. Во-первых, это очень помогает обучать сетку быстрее, а во-вторых, не придется изобретать велосипед.
Исходный проект включал скрипт gen.py
, который буквально собирал .prototxt
файл модели, подставляя входные параметры. Я перенес его в свой проект, немного расширив функционал. Этот скрипт позволяет сгенерировать четыре типа конфигурационных файлов:
Последний вариант я добавил позже, чтобы в скриптах можно было загружать и преобразовывать сетку с BatchNorm, не трогая LMDB базы — иначе при отсутствии базы ничего не работало. (Вообще, мне кажется странным, что в Caffe источник данных задается в архитектуре сети — это как минимум не очень практично).
stride=2
stride=2
stride=2
kernel_size=1
, у второй stride=2
kernel_size=1
, у второй stride=2
kernel_size=1
, у второй stride=2
kernel_size=1
, у второй stride=2
Архитектуру сети я слегка подкорректировал. Список изменений:
Caffe рассчитывает размеры дефолтных рамок так: имея минимальный размер рамки и максимальный , она создает маленькую и большую рамки с размерами и . Поскольку мне хотелось детектировать как можно более мелкие лица, я рассчитал полный stride
для каждого слоя детекций и приравнял минимальный размер рамки к нему. При таких параметрах маленькие дефолтные рамки будут располагаться вплотную друг к другу и не будут пересекаться. Так у нас хотя бы есть гарантия, что пересечение с объектом будет существовать для какой-то рамки. Максимальный размер я установил вдвое больше. Для слоев conv16_2, conv17_2 я выставил размеры на глаз, одинаковыми. Таким образом, для всех слоев составили:
Я использовал два датасета: WIDER Face [14] и FDDB [15]. WIDER содержит много картинок с очень мелкими и размытыми лицами, а FDDB больше тяготеет к крупным изображениям лиц (и на порядок меньше, чем WIDER). В них слегка различается формат аннотирования, но это уже детали.
Для обучения я использовал не все данные: я выкинул слишком маленькие лица (меньше шести пикселей или меньше 2% ширины изображения), выкинул все изображения с соотношением сторон меньше 0.5 или больше 2, выкинул все изображения, помеченные как «размытые» в датасете WIDER, поскольку они соответствовали по большей части совсем мелким лицам, и мне надо было хоть как-то выровнять соотношение мелких и крупных лиц. После этого я сделал все рамки квадратными, расширив наименьшую сторону: я решил, что меня не очень интересуют пропорции лица, а задача для нейросети немного упрощается. Также я выкинул все черно-белые картинки, которых было немного, и на которых скрипт сборки базы данных падает.
Чтобы использовать их для обучения и тестирования, надо собрать из них LMDB базу. Как это делается:
.xml
формате.train.txt
со строками вида "path/to/image.png path/to/labels.xml"
, такой же создается для test.test_name_size.txt
со строками вида "test_image_name height width"
labelmap.prototxt
с числовыми соответствиями меткамЗапускается скрипт ssd-caffe/scripts/create_annoset.py
(пример из Makefile):
python3 /opt/movidius/ssd-caffe/scripts/create_annoset.py --anno-type=detection
--label-map-file=$(wider_dir)/labelmap.prototxt --min-dim=0 --max-dim=0
--resize-width=0 --resize-height=0 --check-label --encode-type=jpg --encoded
--redo $(wider_dir)
$(wider_dir)/trainval.txt $(wider_dir)/WIDER_train/lmdb/wider_train_lmdb ./data
item {
name: "none_of_the_above"
label: 0
display_name: "background"
}
item {
name: "face"
label: 1
display_name: "face"
}
<?xml version="1.0" ?>
<annotation>
<size>
<width>348</width>
<height>450</height>
<depth>3</depth>
</size>
<object>
<name>face</name>
<bndbox>
<xmin>161</xmin>
<ymin>43</ymin>
<xmax>241</xmax>
<ymax>123</ymax>
</bndbox>
</object>
</annotation>
Использование двух датасетов одновременно означает лишь то, что нужно аккуратно слить соответствующие файлы попарно, не забыв правильно прописать пути, а также перемешать файл для обучения.
После этого можно приступать к обучению.
Код для обучения модели можно найти в моем Colab Notebook [16].
Обучение я производил в Google Colaboratory, поскольку мой ноутбук едва справлялся с тестированием сетки, а на обучении вообще зависал. Colaboratory позволила мне обучить сетку достаточно быстро и бесплатно. Подвох лишь в том, что мне пришлось написать скрипт компиляции SSD-Caffe для Colaboratory (включающий такие странные вещи, как перекомпиляцию boost и правку исходников), который выполняется порядка 40 минут. Подробнее можно узнать в моей предыдущей публикации [17].
Есть у Colaboratory еще одна особенность: после 12 часов машина умирает, безвозвратно стирая все данные. Лучший способ избежать потери данных — это монтировать в систему свой гугл диск и сохранять в него веса сети каждые 500-1000 итераций обучения.
Что касается моего детектора, за одну сессию в Colaboratory он успевал отучиться 4500 итераций, и полностью обучался за две сессии.
Качество предсказаний (mean average precision) на выделенном мной тестовом наборе данных (слитые WIDER и FDDB с ограничениями, перечисленными ранее) составило порядка 0.87 для лучшей модели. Для измерения mAP на сохраненных во время обучения весах есть скрипт scripts/plot_map.py
.
Работа детектора на (очень странном) примере из датасета:
Демо-программа с обнаружением лиц находится здесь [3].
Чтобы скомпилировать нейросеть для Neural Compute Stick, нужен Movidius NCSDK [18]: он содержит утилиты для компиляции и профилирования нейросетей, а также C++ и Python API. Стоит заметить, что недавно была выпущена вторая версия, не совместимая с первой: все функции API были зачем-то переименованы, поменялся внутренний формат нейросеток, также добавились FIFO для взаимодействия с NCS и (наконец-то) появилось автоматическое преобразование из float 32 bit в float 16 bit, чего так не хватало в C++. Все свои проекты я обновил до второй версии, но оставил пару костылей для совместимости с первой.
После обучения детектора стоит слить BatchNorm слои с соседними свертками для ускорения нейросети. Этим занимается скрипт merge_bn.py
отсюда [19], который я тоже позаимствовал из проекта Mobilenet-SSD.
Затем необходимо вызвать утилиту mvNCCompile
, например:
mvNCCompile -s 12 -o graph_ssd -w ssd-face.caffemodel ssd-face.prototxt
В Makefile проекта для этого есть цель graph_ssd
. Полученный файл graph_ssd
является описанием нейросети в формате, понятном NCS.
Теперь о том, как взаимодействовать с самим устройством. Процесс не очень сложный, но требует достаточно большого объема кода. Последовательность действий примерно такая:
Практически для каждого действия с NCS есть своя отдельная функция, и в C++ выглядит это весьма громоздко, при этом приходится внимательно следить за освобождением всех ресурсов. Чтобы не нагружать код, я создал класс-обертку для работы с NCS [20]. В нем вся работа по инициализации спрятана в конструктор и функцию load_file
, а по освобождению ресурсов — в деструктор, и работа с NCS сводится к вызову 2-3 методов класса. К тому же, есть удобная функция для объяснения возникших ошибок.
Создаем обертку, передавая в конструктор размер входа и размер выхода (число элементов):
NCSWrapper NCS(NETWORK_INPUT_SIZE*NETWORK_INPUT_SIZE*3, NETWORK_OUTPUT_SIZE);
Загружаем скомпилированный файл с нейросеткой, попутно инициализируя все, что нам необходимо:
if (!NCS.load_file("./models/face/graph_ssd"))
{
NCS.print_error_code();
return 0;
}
Преобразуем изображение в float32 (image
— это cv::Mat
в формате CV_32FC3
) и загружаем на устройство:
if(!NCS.load_tensor_nowait((float*)image.data))
{
NCS.print_error_code();
break;
}
Получаем результат (result
— это свободный float
указатель, буфер результата поддерживается оберткой); до окончания вычислений программа блокируется:
if(!NCS.get_result(result))
{
NCS.print_error_code();
break;
}
На самом деле, в обертке есть и метод, который позволяет загрузить данные и получить результат одновременно: load_tensor((float*)image.data, result)
. Я отказался от его использования не просто так: используя отдельные методы, можно слегка ускорить выполнение кода. После загрузки изображения CPU будет простаивать до тех пор, пока не придет результат выполнения с NCS (в данном случае это порядка 100 мс), и в это время можно заняться полезной работой: считать новый кадр и преобразовать его, а также вывести на экран предыдущие детекции. Именно так и реализована демо-программа, в моем случае это немного увеличивает FPS. Можно пойти дальше и запускать обработку изображений и детектор лиц асинхронно в двух разных потоках — это действительно работает и позволяет еще немного ускориться, однако в демо-программе не реализовано.
Детектор в качестве результата возвращает float массив размера 7*(keep_top_k+1)
. Здесь keep_top_k
— параметр, заданный в .prototxt
файле модели и показывающий, сколько детекций (в порядке уменьшения уверенности) нужно вернуть. Этот параметр, а также параметр, отвечающий за фильтрацию детекций по минимальному значению уверенности, и параметры non maximum suppression можно настроить в .prototxt
файле модели в самом последнем слое. Стоит заметить, что если Caffe возвращает столько детекций, сколько на изображении было найдено, то NCS всегда возвращает keep_top_k
детекций, чтобы размер массива был постоянным.
Сам массив результата устроен так: если рассматривать его как матрицу с keep_top_k+1
строками и 7 столбцами, то в первой строке, в первом элементе будет число детекций, а начиная со второй строки будут сами детекции в формате "garbage, class_index, class_probability, x_min, y_min, x_max, y_max"
. Координаты указаны в диапазоне [0,1], поэтому их необходимо будет домножить на высоту/ширину изображения. В остальных элементах массива будет мусор. При этом non maximum suppression выполняется автоматически, еще до получения результата (похоже, прямо на NCS).
void get_detection_boxes(float* predictions, int w, int h, float thresh,
std::vector<float>& probs, std::vector<cv::Rect>& boxes)
{
int num = predictions[0];
float score = 0;
float cls = 0;
for (int i=1; i<num+1; i++)
{
score = predictions[i*7+2];
cls = predictions[i*7+1];
if (score>thresh && cls<=1)
{
probs.push_back(score);
boxes.push_back(Rect(predictions[i*7+3]*w, predictions[i*7+4]*h,
(predictions[i*7+5]-predictions[i*7+3])*w,
(predictions[i*7+6]-predictions[i*7+4])*h));
}
}
}
Сама демо-программа может быть запущена как на обычном компьютере или ноутбуке с Ubuntu, так и на Raspberry Pi с Raspbian Stretch. Я использую Raspberry Pi 2 model B, но демо должно работать и на других моделях. Makefile проекта содержит две цели для переключения режима: make switch_desk
для компьютера/ноутбука и make switch_rpi
для Raspberry Pi. Принципиальная разница в коде программы заключается лишь в том, что в первом случае для чтения данных с камеры используется OpenCV, а во втором случае — библиотека RaspiCam [21]. Для запуска демо на Raspberry необходимо скомпилировать и установить ее.
Теперь очень важный момент: установка NCSDK. Если следовать стандартным инструкциям установки на Raspberry Pi, ничем хорошим это не кончится: установщик попытается подтащить и скомпилировать SSD-Caffe и Tensorflow. Вместо этого NCSDK нужно скомпилировать в API-only режиме [22]. В этом режиме будут доступны только C++ и Python API (то есть, невозможно будет компилировать и профилировать графы нейросетей). Это значит, что граф нейросети нужно сначала скомпилировать на обычном компьютере, а затем скопировать на Raspberry. Для удобства я добавил в репозиторию два скомпилированных файла, для YOLO и для SSD.
Еще один интересный момент — это чисто физическое подключение NCS к Raspberry. Казалось бы, несложно подключить ее к USB-разъему, но нужно помнить, что ее корпус при этом заблокирует остальные три разъема (он довольно здоровый, так как выполняет функцию радиатора). Самый простой выход — подключить ее через USB-кабель. Но стоит иметь в виду, что кабель вносит дополнительную задержку при передаче данных — не очень большую, но заметную. Я пробовал два разных кабеля, один 2 м, второй 30 см, и они оба вносили примерно одинаковую задержку.
Теперь насчет питания NCS. Согласно документации, потребляет она до 1 ватта (при 5 вольтах на USB разъеме это будет до 200 ma; для сравнения: камера Raspberry потребляет до 250 ma). При питании от обычного зарядного устройства на 5 вольт, 2 ампера все прекрасно работает. Однако при попытке подключить две или больше NCS к Raspberry могут возникнуть проблемы. В этом случае рекомендуют использовать USB-разветвитель с возможностью внешнего питания.
На Raspberry демо работает медленнее, чем на компьютере/ноутбуке: 7.2 FPS против 10.4 FPS. Связано это с несколькими факторами: во-первых, от вычислений на CPU избавиться невозможно, а выполняются они намного медленнее; во-вторых, сказывается скорость передачи данных (вспомним про USB-кабель).
Также для сравнения я попытался запустить на Raspberry YOLOv2 детектор лиц из своей первой статьи, но заработал он очень плохо: при скорости в 3.6 FPS он пропускает множество лиц даже на простых кадрах. Судя по всему, он очень чувствителен к параметрам входного изображения, качество которого в случае камеры Raspberry далеко от идеала. SSD работает намного стабильнее, хотя пришлось немного подкрутить параметры видео в настройках RapiCam. он тоже иногда пропускает лица на кадре, но делает это довольно редко. Для увеличения стабильности в реальных приложениях можно добавить простой centroid tracker [23].
К слову: то же самое можно воспроизвести и на Python, есть туториал на PyImageSearch [24] (используется Mobilenet-SSD для задачи object detection).
Также я испытал пару идей по ускорению самой нейросети:
Первая идея: можно оставить только детекции слоев conv11
и conv13
, а все лишние слои удалить. Получится детектор, который детектирует только мелкие лица и работает немного быстрее. В целом, не стоит того.
Вторая идея была интересная, но не заработала: я попытался выбросить из нейросетки свертки с весами, близкими к нулю, рассчитывая, что она станет быстрее. Однако таких сверток оказалось немного, и их удаление только слегка замедлило нейросеть (единственная догадка: это связано с тем, что число каналов перестало быть степенью двойки).
Об обнаружении лиц на Raspberry я задумался довольно давно, как о подзадаче моего робототехнического проекта. Мне не понравились классические детекторы по соотношению скорости и качества, и я решил попробовать нейросетевые методы, заодно испытав Neural Compute Stick, в результате чего появились два проекта на GitHub и три статьи на Хабре (включая текущую). В целом, результат меня устраивает — скорее всего, именно этот детектор я буду использовать в своем роботе (возможно, о нем будет еще одна статья). Стоит заметить, что мое решение может оказаться не оптимальным — все же, это учебный проект, выполненный отчасти из любопытства к NCS. Все же, надеюсь, что эта статья окажется кому-нибудь полезной.
Автор: BeloborodovDS
Источник [25]
Сайт-источник PVSM.RU: https://www.pvsm.ru
Путь до страницы источника: https://www.pvsm.ru/raspberry-pi/294518
Ссылки в тексте:
[1] Intel Movidius: https://www.movidius.com/
[2] обучение детектора: https://github.com/BeloborodovDS/MobilenetSSDFace
[3] демо с обнаружением лиц: https://github.com/BeloborodovDS/NCS-face
[4] В своей первой статье: https://habr.com/post/347438/
[5] YOLOv2: https://github.com/dannyblueliu/YOLO-Face-detection
[6] Darknet: https://pjreddie.com/darknet/
[7] Caffe: http://caffe.berkeleyvision.org/
[8] SSD: https://github.com/weiliu89/caffe/tree/ssd
[9] Mobilenet: https://arxiv.org/abs/1704.04861
[10] BatchNorm: https://arxiv.org/abs/1502.03167
[11] более продвинутый вариант: https://arxiv.org/abs/1801.04381
[12] готовым Mobilenet-SSD детектором: https://github.com/chuanqi305/MobileNet-SSD
[13] PASCAL VOC0712: http://host.robots.ox.ac.uk/pascal/VOC/
[14] WIDER Face: http://mmlab.ie.cuhk.edu.hk/projects/WIDERFace/
[15] FDDB: http://vis-www.cs.umass.edu/fddb/
[16] Colab Notebook: https://colab.research.google.com/drive/1LExcFZO8vN46xrJ8deG159eIUaW0kB-H
[17] в моей предыдущей публикации: https://habr.com/post/413229/
[18] Movidius NCSDK: https://movidius.github.io/ncsdk/
[19] отсюда: https://github.com/BeloborodovDS/MobilenetSSDFace/tree/master/models/ssd_voc
[20] класс-обертку для работы с NCS: https://github.com/BeloborodovDS/NCS-face/tree/master/wrapper
[21] RaspiCam: http://www.uco.es/investiga/grupos/ava/node/40
[22] скомпилировать в API-only режиме: https://movidius.github.io/blog/ncs-apps-on-rpi/
[23] centroid tracker: https://www.pyimagesearch.com/2018/07/23/simple-object-tracking-with-opencv/
[24] туториал на PyImageSearch: https://www.pyimagesearch.com/2018/02/19/real-time-object-detection-on-the-raspberry-pi-with-the-movidius-ncs/
[25] Источник: https://habr.com/post/424973/?utm_campaign=424973
Нажмите здесь для печати.