OpenVINO хакатон: распознаем голос и эмоции на Raspberry Pi +11


30 ноября — 1 декабря в Нижнем Новгороде прошел OpenVINO хакатон. Участникам предлагалось создать прототип продуктового решения с использованием Intel OpenVINO toolkit. Организаторами был предложен список примерных тем, на которые можно было ориентироваться при выборе задачи, но финальное решение оставалось за командами. Кроме этого, поощрялось использование моделей, которые не входят в продукт.



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


В хакатоне участвовало более 10 команд. Приятно, что некоторые из них приехали из других регионов. Местом проведения хакатона был выбран комплекс “Кремлевский на Почаине”, где внутри были развешаны старинные фотографии Нижнего Новгорода, антуражно! (напоминаю, что на данный момент центральный офис компании Intel расположен именно в Нижнем Новгороде). На написание кода участникам отводилось 26 часов, в конце было необходимо презентовать своё решение. Отдельным плюсом было наличие демо-сессии, чтобы убедиться, что всё задуманное правда реализовано, а не осталось идеями в презентации. Мерч, снеки, еда, всё тоже было!


Кроме этого, компания Intel по желанию предоставляла камеры, Raspberry PI, Neural Compute Stick 2.


Выбор задачи


Одной из самых сложных частей подготовки к хакатону со свободной тематикой является выбор задачи. Сразу решили придумывать что-то, чего в продукте ещё нет, так как в анонсе было сказано, что это всячески приветствуется.


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


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

Развиваем идею: возьмем за основу идею для retail сегмента. Можно определять удовлетворенность покупателя на кассах магазинов. Если кто-то из покупателей недоволен обслуживанием и начинает повышать тон — можно сразу звать администратора на помощь.
В этом случае нужно добавить распознавание человека по голосу, это позволит нам различать сотрудников магазина от покупателей, выдавать аналитику по каждому индивидууму. Ну и кроме того, можно будет анализировать поведение самих сотрудников магазина, оценивать атмосферу в коллективе, звучит неплохо!


Формируем требования к нашему решению:


  • Маленький размер целевого девайса
  • Работа в реальном времени
  • Низкая цена
  • Легкая масштабируемость

В итоге в качестве целевого девайса выбираем Raspberry Pi 3 c Intel NCS 2.


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


Дело за малым: нужно раздобыть микрофон. Подойдет и обычный USB-микрофон, правда он не будут смотреться хорошо вместе с RPI. Но и тут решение буквально “лежит под боком”. Для записи голоса решаем использовать плату Voice Bonnet из набора Google AIY Voice Kit, на которой есть распаянный стерео микрофон.


Скачиваем Raspbian из репозитория AIY projects и заливаем на флешку, тестируем, что микрофон работает с помощью следующей команды (она запишет аудио длиной в 5 секунд и сохранит в файлик):


arecord -d 5 -r 16000 test.wav

Сразу отмечу, что микрофон очень чувствительный и хорошо ловит шумы. Чтобы исправить это, зайдем в alsamixer, выберем Capture devices и снизим уровень входного сигнала до 50-60%.



Дорабатываем корпус напильником и всё влезает, можно даже закрыть крышкой


Добавляем кнопку-индикатор


Во время разбора AIY Voice Kit на части вспоминаем, что там есть RGB-кнопка, подсветкой которой можно управлять программно. Ищем “Google AIY Led” и находим документацию: https://aiyprojects.readthedocs.io/en/latest/aiy.leds.html
Почему бы не использовать эту кнопку для отображения распознанной эмоции, у нас всего 7 классов, а в кнопке 8 цветов, как раз хватает!


Подключаем кнопку по GPIO к Voice Bonnet, подгружаем нужные библиотеки (они уже установлены в диструбутиве от AIY projects)


from aiy.leds import Leds, Color
from aiy.leds import RgbLeds

Cоздадим dict, в котором каждой эмоции будет соответствовать цвет в виде RGB Tuple и объект класса aiy.leds.Leds, через который будем обновлять цвет:


led_dict = {'neutral': (255, 255, 255), 'happy': (0, 255, 0), 'sad': (0, 255, 255), 'angry': (255, 0, 0), 'fearful': (0, 0, 0), 'disgusted':  (255, 0, 255), 'surprised':  (255, 255, 0)} 
leds = Leds()

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


leds.update(Leds.rgb_on(led_dict.get(classes[prediction])))


Кнопочка, гори!


Работаем с голосом


Будем использовать pyaudio для захвата потока с микрофона и webrtcvad для фильтрации шума и детектирования голоса. Кроме этого, создадим очередь, в которую будем асинхронно добавлять и забирать отрывки с голосом.


Так как у webrtcvad есть ограничение на размер подаваемого фрагмента — он должен быть равен 10/20/30мс, а обучение модели для распознавания эмоций (как мы далее узнаем) проводилось на датасете 48кГц, будем захватывать чанки размера 48000?20мс/1000?1(моно)=960 байт. Webrtcvad будет возвращать True/False для каждого из таких чанков, что соответствует наличию или отсутствию голоса в чанке.


Реализуем следующую логику:


  • Будем добавлять в list те чанки, где есть голос, если голоса нет, то инкрементируем счетчик пустых чанков.
  • Если счетчик пустых чанков >=30 (600 мс), то смотрим на размер листа накопившихся чанков, если он >250, то добавляем в очередь, если же нет, считаем, что длины записи недостаточно, чтобы подать её на модель для идентификации говорящего.
  • Если же счетчик пустых чанков всё ещё < 30, а размер листа накопившихся чанков превысил 300, то добавим отрывок в очередь для более точного предсказания. (ибо со временем эмоциям свойственно меняться)

 def to_queue(frames):
    d = np.frombuffer(b''.join(frames), dtype=np.int16)
    return d

framesQueue = queue.Queue()
def framesThreadBody():
    CHUNK = 960
    FORMAT = pyaudio.paInt16
    CHANNELS = 1
    RATE = 48000

    p = pyaudio.PyAudio()
    vad = webrtcvad.Vad()
    vad.set_mode(2)
    stream = p.open(format=FORMAT,
                channels=CHANNELS,
                rate=RATE,
                input=True,
                frames_per_buffer=CHUNK)
    false_counter = 0
    audio_frame = []
    while process:
        data = stream.read(CHUNK)
        if not vad.is_speech(data, RATE):
            false_counter += 1
            if false_counter >= 30:
                if len(audio_frame) > 250:              
                    framesQueue.put(to_queue(audio_frame,timestamp_start))
                    audio_frame = []
                    false_counter = 0

        if vad.is_speech(data, RATE):
            false_counter = 0
            audio_frame.append(data)
            if len(audio_frame) > 300:                
                    framesQueue.put(to_queue(audio_frame,timestamp_start))
                    audio_frame = []

Пришло время поискать в открытом доступе предобученные модели, идем на github, гуглим, но помним, что у нас есть ограничение на используемую архитектуру. Это довольно сложная часть, потому что приходится тестировать модели на своих входных данных, а кроме того, конвертировать во внутренний формат OpenVINO — IR (Intermediate Representation). Мы пробовали около 5-7 различных решений с github, и если модель для распознавания эмоций заработала сразу, то вот с распознаванием по голосу пришлось посидеть дольше — там используются более сложные архитектуры.


Останавливаемся на следующих:


  • Эмоции из голоса — https://github.com/alexmuhr/Voice_Emotion
    Работает она по следующему принципу: аудио нарезается на отрывки определенного размера, для каждого из этих отрывков выделяем MFCC и далее подаем их на вход в CNN
  • Распознавание по голосу — https://github.com/linhdvu14/vggvox-speaker-identification
    Тут вместо MFCC работаем со спектрограммой, после FFT подаем сигнал в CNN, где на выходе получаем векторное представление голоса.

Далее речь пойдет о конвертации моделей, начнем с теории. OpenVINO включает в себя несколько модулей:


  • Open Model Zoo, модели из которого можно было использовать и включать в свой продукт
  • Model Optimzer, благодаря которому можно переконвертировать модель из различных форматов фреймворков (Tensorflow, ONNX e.t.c) в формат Intermediate Representation, с которым далее мы и будем работать
  • Inference Engine позволяет запускать модели в IR формате на процессорах Intel, чипах Myriad и ускорителях Neural Compute Stick
  • Наиболее эффективная версия OpenCV (с поддержкой Inference Engine)
    Каждая модель в формате IR описывается двумя файлами: .xml и .bin.
    Модели конвертируются в формат IR через Model Optimizer следующим образом:
    python /opt/intel/openvino/deployment_tools/model_optimizer/mo_tf.py --input_model speaker.hdf5.pb --data_type=FP16 --input_shape [1,512,1000,1]

    --data_type позволяет выбрать формат данных, с которым будет работать модель. Поддерживаются FP32, FP16, INT8. Выбор оптимального типа данных может дать хороший прирост к производительности.
    --input_shape указывает на размерность входных данных. Возможность динамически её менять вроде бы присутствует в C++ API, но мы так далеко копать не стали и для одной из моделей просто зафиксировали её.
    Далее попробуем загрузить уже сконвертированную модель в IR формате через DNN модуль в OpenCV и сделать forward на неё.

    import cv2 as cv
    emotionsNet = cv.dnn.readNet('emotions_model.bin',
                              'emotions_model.xml')
    emotionsNet.setPreferableTarget(cv.dnn.DNN_TARGET_MYRIAD)

    Последняя строчка в данном случае позволяет перенаправить вычисления на Neural Compute Stick, базово вычисления выполняются на процессоре, но в случае с Raspberry Pi это не прокатит, понадобится стик.



    Далее логика следующая: разделим наше аудио на окна определенного размера (у нас это 0.4с), каждое из этих окон преобразуем в MFCC, которые затем подадим на сетку:


    emotionsNet.setInput(MFCC_from_window)
    result = emotionsNet.forward()

    После возьмем наиболее часто встречающийся класс для всех окон. Простое решение, но для хакатона и не нужно выдумывать что-то слишком заумное, только если есть время. У нас работы ещё много, поэтому идем дальше — разбираемся с распознаванием по голосу. Нужно сделать некую базу, в которой бы хранились спектрограммы заранее записанных голосов. Так как времени осталось немного, решаем этот вопрос как можем.


    А именно, создаем скрипт для записи отрывка голоса (работает он так же, как описано выше, только при прерывании с клавиатуры он будет сохранять голос в файлик).


    Пробуем.:


    python3 voice_db/record_voice.py test.wav

    Записываем голоса нескольких человек (в нашем случае троих членов команды)
    Далее для каждого записанного голоса выполняем fast fourier transform, получаем спектрограмму и сохраняем в виде numpy array (.npy):


    for file in glob.glob("voice_db/*.wav"):
            spec = get_fft_spectrum(file)
            np.save(file[:-4] + '.npy', spec)

    Подробнее в файле create_base.py
    В итоге при запуске основного скрипта мы в самом начале получим эмбеддинги из этих спектрограмм:


    for file in glob.glob("voice_db/*.npy"):
        spec = np.load(file)
        spec = spec.astype('float32')
        spec_reshaped = spec.reshape(1, 1, spec.shape[0], spec.shape[1])
        srNet.setInput(spec_reshaped)
        pred = srNet.forward()
        emb = np.squeeze(pred)

    После получения эмбеддинга из прозвучавшего отрезка сможем определить, кому он принадлежит, взяв cosine distance от отрывка до всех голосов в базе (чем меньше, тем вероятнее) — для демо мы выставили порог 0.3):


            dist_list = cdist(emb, enroll_embs, metric="cosine")
            distances = pd.DataFrame(dist_list, columns = df.speaker)

    В конце отмечу то, что скорость инференса была быстрой и позволяла добавить ещё 1-2 модели (на сэмпл длиной 7 секунд на инференс уходило 2.5). Добавить новые модели мы уже не успевали и сфокусировались на написании прототипа веб-приложения.


    Веб-приложение


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


    Бэкенд представляет из себя сквозной канал сообщений между фронтом и Raspberry Pi, основанный на технологии websocket (http over tcp protocol).


    Первым этапом является получение обработанной информации с распберри, то есть упакованные в json предикты, которые на середине своего пути сохраняются в базу данных, дабы можно было формировать статистику об эмоциональном фоне пользователя за период. Далее этот пакет отправляется на фронтенд, который использует подписку и получение пакетов с эндпоинта вебсокета. Весь механизм бэкенд построен на языке golang, выбор на него пал тем, что он хорошо подходит для асинхронных задач, с которыми горутины хорошо справляются.
    При доступе к эндпоинту пользователь регистрируется и заносится в структуру, затем происходит получение его сообщения. И пользователь, и сообщение заносятся в общий hub, из которого сообщения уже отправляются дальше (на подписанный фронт), а если пользователь закрывает соединение (распберри или фронт), то его подписка аннулируется, и он удаляется из hub.



    Ожидаем коннект с бэка


    Front-end представляет собой web-приложение, написанное на JavaScript с использованием библиотеки React для ускорения и упрощения процесса разработки. Целью данного приложения является визуализация данных, полученных при помощи алгоритмов, запущенных на back-end стороне и непосредственно Raspberry Pi. На странице имеется роутинг по разделам, реализованный при помощи react-router, но основной интерес представляет главная страница, где в режиме реального времени поступает непрерывный поток данных с сервера по технологии WebSocket. Raspberry Pi детектирует голос, определяет принадлежность к определённому человеку из зарегистрированной базы и высылает список probability клиенту. Клиент отображает последние актуальные данные, выводит аватарку человека, который с наибольшей вероятностью говорил в микрофон, а также эмоцию, с которой он произносит слова.



    Главная страница с обновляемыми предиктами


    Заключение


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


    Отмечу, что общая стоимость нашего решения составила 150$:


    • Raspberry Pi 3 ~ 35$
    • Google AIY Voice Bonnet (можно взять плату respeaker) ~ 15$
    • Intel NCS 2 ~ 100$

    Как улучшить:


    • Использовать регистрацию с клиента — просить прочитать текст, который генерим случайно
    • Добавить ещё несколько моделей: по голосу можно определять пол и возраст
    • Разделять одновременно звучащие голоса (диаризация)

    Репозиторий: https://github.com/vladimirwest/OpenEMO



    Уставшие, но счастливые мы


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




К сожалению, не доступен сервер mySQL