Сергей РезникСтатьи

Kinect SDK. Часть первая

Автор:

Получаем изображение с камер кинекта.

Kinect | Kinect SDK. Часть первая

Введение
Подготовительная часть
Инициализая и настройка
Получение RGB изображения
Получение изображения с камеры глубины

Введение


После краткого знакомства с Kinect SDK давайте поизучаем его поближе и попытаемся получить данные с камер устройства. На момент написания статьи Kinect SDK все еще версии 1.00.11 от 16-ого июня и множество описанных (и довольно интересных) в справке функций не работают. Но это не страшно. Существующего функционала достаточно для исследований и написания простеньких приложений и игр. Собственно в этой статье я расскажу о том, как я прикручивал Kinect SDK в свой движок. Я буду использовать некоторые свои типы данных, но думаю они всем будут понятны.

Подготовительная часть


Итак, цель: сделать модуль-обертку над Kinect SDK для своего движка. Так как Kinect SDK доступен только для Windows, то всю реализацию мы завернем в private implementation. Но для начала создадим класс, который собственно и будет отвечать за работу с кинектом. Назовем его просто - Kinect. Думаю логично, что первым делом нужно проверить - а есть ли вообще у нас кинект?
  class KinectPrivate;
  class Kinect
  {
  public:
    static bool deviceAvailable();

  public:
    Kinect(KinectDelegate*);
    ~Kinect();

  private:
    friend class KinectPrivate;

    KinectDelegate* _delegate;
    KinectPrivate* _private;
    bool _deviceAvailable;
  };

Средство такой проверки есть в Kinect SDK. Это метод MSR_NUIGetDeviceCount. То есть:

bool Kinect::deviceAvailable()
{
  int deviceCount = 0;
  HRESULT result = MSR_NUIGetDeviceCount(&deviceCount);
  return (result == S_OK) && (deviceCount > 0);
}

Так как вся работа с Kinect SDK будет осуществляться в PIMPL, то конструктор и деструктор нашего класса Kinect очевидны:

Kinect::Kinect(KinectDelegate* delegate) : _delegate(delegate), _private(0)
{
  _deviceAvailable = Kinect::deviceAvailable();
  if (_deviceAvailable) 
    _private = new KinectPrivate(this);
}

Kinect::~Kinect()
{
  delete _private;
}

Идем дальше.

Инициализая и настройка

Итак, допустим что у нас есть кинект, кабель для его соединения с компьютером (который поставляется вместе с кинектом, если тот покупался отдельно, либо стоит около 40 долларов) и все готово к работе.
Инициализация происходит довольно просто. Мы при помощи флагов указываем какие данные мы хоти получать от устройства:

HRESULT result = NuiInitialize(NUI_INITIALIZE_FLAG_USES_DEPTH_AND_PLAYER_INDEX | NUI_INITIALIZE_FLAG_USES_SKELETON | NUI_INITIALIZE_FLAG_USES_COLOR);

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

HRESULT NuiImageStreamOpen(
   NUI_IMAGE_TYPE eImageType,
   NUI_IMAGE_RESOLUTION eResolution,
   DWORD dwImageFrameFlags_NotUsed,
   DWORD dwFrameLimit,
   HANDLE hNextFrameEvent,
   HANDLE *phStreamHandle
);

Теперь по порядку.
eImageType - тип изображения, который определяет формат данных.
eResolution определяет размеры кадра (об этом далее).
dwImageFrameFlags_NotUsed пока не используется.
dwFrameLimit - количество кадров, которые будут буферизироваться. Оно не может быть больше чем NUI_IMAGE_STREAM_FRAME_LIMIT_MAXIMUM (сейчас это 4). Рекомендуют использовать значение 2.
hNextFrameEvent - событие, которое будет сигнализировать нам о том, что новый кадр сформирован. Очень важный параметр (об этом далее)
phStreamHandle - указатель, в который, в случае успешного выполнения, будет записан идентификатор потока.

Теперь подробнее о разрешении изображения. В документации приведена таблица, которая описывает размеры кадра для каждого из типов данных. Так для NUI_IMAGE_TYPE_DEPTH_AND_PLAYER_INDEX разрешение может быть либо 80x60 либо 320x240. Для NUI_IMAGE_TYPE_DEPTH - уже может быть до 640x480. А для NUI_IMAGE_TYPE_COLOR - либо 640x480 либо 1280x1024. Подробнее об типах данных читайте ниже.
Как я уже сказал - идентификатор события - это очень нужный и полезный параметр. Он позволяет сделать нам хорошее многопоточное приложение. Для того, чтобы не тормозить основной поток, мы из другого потока будем получать сведения о том, что кадр уже готов и просто будем забирать этот кадр, вместо того, чтобы ждать его в основном потоке. Для каждого из типов данных нам понадобится одно событие. Также создадим событие, которое сообщит о завершении работы с кинектом. Обратите внимание, что событие должно быть "manual reset". То есть после вызова SetEvent внутри Kinect SDK будет происходить вызов ResetEvent. Если создать не manual-reset событие, получим скорость обновления данных около 15 кадров в секунду. Да, забыл сказать, что в нормальном режиме имеем около 30-ти кадров в секунду.
Итак, создадим события для каждых типов данных:

  _threadStopEvent = CreateEvent(0, false, false, 0);
  _kinectImageNextFrameEvent = CreateEvent(0, true, false, 0);
  _kinectDepthNextFrameEvent = CreateEvent(0, true, false, 0);
  _kinectSkeletonNextFrameEvent = CreateEvent(0, true, false, 0);

И откроем потоки. Следует заметить, что метода закрытия потока не существует - они освобождаются вместе с вызовом NuiShutdown(). Так как мы не гонимся за большим размером выходного изображения - будем использовать 640x480 для цветной картинки

  result = NuiImageStreamOpen(NUI_IMAGE_TYPE_COLOR, NUI_IMAGE_RESOLUTION_640x480, 0, 2, 
    _kinectImageNextFrameEvent, &_kinectImageStream);

Для глубины и индекса игрока будем использовать максимально возможное разрешение - 320x240:

  result = NuiImageStreamOpen(NUI_IMAGE_TYPE_DEPTH_AND_PLAYER_INDEX, NUI_IMAGE_RESOLUTION_320x240, 0, 2, 
    _kinectDepthNextFrameEvent, &_kinectDepthStream);

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

  result = NuiSkeletonTrackingEnable(_kinectSkeletonNextFrameEvent, 0);

Теперь создадим поток, в котором будем обрабатывать события, приходящие от Kinect SDK:

  _thread = CreateThread(0, 0, threadFunc, this, 0, 0);

Сразу рассмотрим как это все правильно выключить. Мы сообщаем потоку о том, что работа завершена, после чего завершаем поток и удаляем все события. Также вызываем метод NuiShutdown()

  if (_threadStopEvent)
  {
    SetEvent(_threadStopEvent);
    if (_thread)
    {
      WaitForSingleObject(_thread, INFINITE);
      CloseHandle(_thread);
    }

    CloseHandle(_threadStopEvent);
  }

  NuiShutdown();

  if (_kinectSkeletonNextFrameEvent)
    CloseHandle(_kinectSkeletonNextFrameEvent);

  if (_kinectDepthNextFrameEvent)
    CloseHandle(_kinectDepthNextFrameEvent);

  if (_kinectImageNextFrameEvent)
    CloseHandle(_kinectImageNextFrameEvent);

Метод, который выполняется в отдельном потоке предельно прост - мы в цикле ждем наступления одного из событий и после чего вызываем соответствующий метод:

enum KinectEvent
{
  KinectEvent_Shutdown,
  KinectEvent_NextImageFrame,
  KinectEvent_NextDepthFrame,
  KinectEvent_NextSkeletonFrame,
  KinectEvent_max
};

DWORD WINAPI KinectPrivate::threadFunc(void* param)
{
  std::cout << "Running kinect thread..." << std::endl;
  KinectPrivate* kp = reinterpret_cast<KinectPrivate*>(param);

  HANDLE eventList[KinectEvent_max] = 
  {
    kp->_threadStopEvent,
    kp->_kinectImageNextFrameEvent,
    kp->_kinectDepthNextFrameEvent,
    kp->_kinectSkeletonNextFrameEvent,
  };

  DWORD eventIndex = static_cast<DWORD>(-1);
  DWORD numEvents = sizeof(eventList) / sizeof(HANDLE);

  bool running = true;
  while (running)
  {
    eventIndex = WaitForMultipleObjects(numEvents, eventList, false, 100);
    if (eventIndex == KinectEvent_Shutdown)
    {
      running = false;
    }
    else if (eventIndex == KinectEvent_NextImageFrame)
    {
      kp->didGetNewImageFrame();
    }
    else if (eventIndex == KinectEvent_NextDepthFrame)
    {
      kp->didGetNewDepthFrame();
    }
    else if (eventIndex == KinectEvent_NextSkeletonFrame)
    {
      kp->didGetNewSkeletonFrame();
    }
  }

  std::cout << "Kinect thread has finished." << std::endl;
  return 0;
}

Получение RGB изображения


Итак, мы подобрались к самому интересному. Если у нас получилось все правильно, то каждый раз, когда сформируется новый кадр будет вызван наш метод didGetNewImageFrame. Внутри него мы должны будем получить новый кадр, получить сырые данные изображения и обязательно "зарелизить" полученный кадр, иначе кадров чере 5-8 все будет очень плохо (программа просто упадет).
Получение кадра происходит путем вызова метода NuiImageStreamGetNextFrame. Открываем его в справке:
HRESULT NuiImageStreamGetNextFrame(
   HANDLE hStream,
   DWORD dwMillisecondsToWait,
   CONST NUI_IMAGE_FRAME **ppcImageFrame
);

hStream - наш открытый поток
dwMillisecondsToWait - количество миллисекунд, после которого данный метод вернет управление, в случае, если следующего кадра не будет.
ppcImageFrame - указатель на указатель на структуру NUI_IMAGE_FRAME. Данный метод сам создаст нам объект, а потом его надо будет передать в метод NuiImageStreamReleaseFrame для удаления.

Так как мы точно знаем, что следующий кадр уже готов, то мы указываем dwMillisecondsToWait равным нулю:

  const NUI_IMAGE_FRAME* frame = 0;
  HRESULT result = NuiImageStreamGetNextFrame(_kinectImageStream, 0, &frame);
  if (FAILED(result)) return;

В случае успеха у нас будет указатель на структуру NUI_IMAGE_FRAME, в которой много полей (часть из которых не используется), но более всего нам интересен указатель на NuiImageBuffer, который собственно и хранит данные о изображении. У него есть метод, позволяющие узнать размер изображения - это GetLevelDesc и метод LockRect (не забываем потом вызывать UnlockRect!) который возвращает нам маленькую, но такую важную структуру KINECT_LOCKED_RECT. Эта структура и содержит сырые данные изображения:

typedef struct _KINECT_LOCKED_RECT{
    INT    Pitch;
    void * pBits;
} KINECT_LOCKED_RECT;

Небольшое отступление. Как я уже говорил - целью у нас является написать обертку для Kinect SDK. То есть мы не просто так получаем данные изображения. Их потом надо как-то использовать. Так вот, чтобы передать управление наружу я решил использовать паттерн делегирования, и написал вот такой класс:

  typedef unsigned short* KinectDepthData;
  typedef vec4ub* KinectColorData;

  class KinectDelegate
  {
  public:
    virtual void kinectDidGetImageFrame(const vec2i& size, KinectColorData data) = 0;
    virtual void kinectDidGetDepthFrame(const vec2i& size, KinectDepthData data) = 0;
  };

Наследуя этот класс, мы реализуем обработку данных с камеры. К слову сказать, данные изображения поступают в BGRA формате (для NUI_IMAGE_TYPE_COLOR), при этом A всегда равен нулю, что немного напрягает (может это разерезвированно для последующего использования?), а данные с камеры глубины хранятся в unsigned short. Но об этом дальше.

Итак, цельный метод получения данных с камеры выглядит вот так:

void KinectPrivate::didGetNewImageFrame()
{
  const NUI_IMAGE_FRAME* frame = 0;
  HRESULT result = NuiImageStreamGetNextFrame(_kinectImageStream, 0, &frame);
  if (FAILED(result)) return;

  KINECT_SURFACE_DESC surfaceDescription = { };
  if (!FAILED(frame->pFrameTexture->GetLevelDesc(0, &surfaceDescription)))
  {
    KINECT_LOCKED_RECT lockedRect = { };
    if (!FAILED(frame->pFrameTexture->LockRect(0, &lockedRect, 0, 0)))
    {
      _kinect->_delegate->kinectDidGetImageFrame(vec2i(surfaceDescription.Width, surfaceDescription.Height), 
        static_cast<KinectColorData>(lockedRect.pBits));

      frame->pFrameTexture->UnlockRect(0);
    }
  }

  NuiImageStreamReleaseFrame(_kinectImageStream, frame);
}

Что делать с этими данным дальше - решайте сами. Я запихиваю их в текстуру и вывожу на экран.

Получение изображения с камеры глубины


В целом, процесс получения данных с камеры глубины идентичен получению цветных данных, вся соль в формате этих данных. Приведу метод получения данных с камеры глубины (он абсолютно такой же, как и в предыдущем разделе, за исключеним других переменных и констант):
void KinectPrivate::didGetNewDepthFrame()
{
  const NUI_IMAGE_FRAME* frame = 0;
  HRESULT result = NuiImageStreamGetNextFrame(_kinectDepthStream, 0, &frame);
  if (FAILED(result)) return;

  KINECT_SURFACE_DESC surfaceDescription = { };
  if (!FAILED(frame->pFrameTexture->GetLevelDesc(0, &surfaceDescription)))
  {
    KINECT_LOCKED_RECT lockedRect = { };
    if (!FAILED(frame->pFrameTexture->LockRect(0, &lockedRect, 0, 0)))
    {
      _kinect->_delegate->kinectDidGetDepthFrame(vec2i(surfaceDescription.Width, surfaceDescription.Height), 
        static_cast<KinectDepthData>(lockedRect.pBits));
      frame->pFrameTexture->UnlockRect(0);
    }
  }

  NuiImageStreamReleaseFrame(_kinectDepthStream, frame);
}

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

X -> XXXXXXXXXXXX -> XXX
^    ^               ^
не исп.              индекс игрока
     |
     – глубина

Индекс игрока может быть 0 (если игроков нет), 1 или 2. Получить его можно с помощью побитного умножения:

unsigned short playerIndex = value & 0x7;

Оставшуюся часть сдвигаем вправо на 3 бита:

value = value >> 3;

Теперь у нас есть значение от 0 до 4095. Чтобы запихнуть его в RGBA текстуру (состоящую из байтов) нужно поделить значение на 16 - получим значения в диапазоне от 0 до 255. Теперь можно играться с этим как угодно. Например выводить глубину, а игроков подсвечивать цветами, или оставлять только игроков, итд. Вот например:

Kinect depth visualized | Kinect SDK. Часть первая

На этом все. В следующей части я постараюсь рассказать о получении скелета, а так же о том, как совместить данные с RGB камеры и камеры глубины (да да, все не так просто - камеры разнесены на некоторое расстояние). Спасибо за внимание.

#Kinect, #Xbox

1 августа 2011

Комментарии [11]