21 октября 2011 г.

Kinect и XNA: первая кровь

Итак, наконец-то оторвавшись от Rage, продолжим :) Крови сегодня не будет (да, «все врут»), зато будет первый код: мы посмотрим как получать кадры от камеры и сенсора глубины, а также повертим «головой» Kinect вверх-вниз из кода на C# в нашем XNA-приложении. Соответственно, кроме описанных ранее требований, нам также понадобится XNA Game Studio (я использую 4.0, хотя она уже обновилась — разницы в коде для нас быть не должно).


Для самых нетерпеливых:

Первое что нам нужно сделать — добавить в проект (XNA Windows Game) ссылку на библиотеку Kinect, она находится на вкладке .NET под названием Microsoft.Research.Kinect: 


Теперь пара слов об именовании — в своих проектах я стараюсь придерживаться следующих принципов:
  • все названия оформляются в стиле CamelCase, при этом имена переменных и экземпляров классов — в lowerCamelCase
  • названия глобальных переменных начинаются со знака подчёркивания: _global
  • названия переданных в качестве параметра переменных начинаются с «p_»: p_param
  • названия локальных переменных остаются как есть: local

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

Microsoft.Research.Kinect разделяется на две части Audio и Nui. Руководствуясь описанным выше принципом подключим нужное нам пространство имён:

using K = Microsoft.Research.Kinect.Nui;
 
Теперь создадим экземпляр класса Runtime, представляющий сам сенсор Kinect:

        private K.Runtime _runtime = new K.Runtime();

Для включения сенсора его надо инициализировать указав режимы работы, которые мы будем использовать. Они задаются комбинацией значений перечисления RuntimeOptions:
  • UseColor — RGB камера
  • UseDepth — сенсор глубины
  • UseDepthAndPlayerIndex — сенсор глубины с определением положения шести человек
  • UseSkeletalTracking — отслеживание скелетов двух игроков

Добавим инициализацию устройства с нужными нам режимами в метод Initialize():

        protected override void Initialize()
        {
            _runtime.Initialize(
                    //K.RuntimeOptions.UseDepthAndPlayerIndex |
                    //K.RuntimeOptions.UseSkeletalTracking |
                    K.RuntimeOptions.UseDepth |
                    K.RuntimeOptions.UseColor
                    );
            
            base.Initialize();
        }
 
Также добавим код для корректного завершения работы с Kinect при закрытии приложения:

         protected override void UnloadContent()
        {
            _runtime.Uninitialize();
        }

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

        private Texture2D _RGB;
        private Texture2D _Depth;
        
        private Color[] _textureData;
        private int _tW = 640;
        private int _tH = 480;
        
        private int _distMin = 850;
        private int _distMax = 4000;
        private int _distOffset;

Создадим экземпляры текстур в методе LoadContent(), чтобы ссылки не были пустыми:

        protected override void LoadContent()
        {
            _spriteBatch = new SpriteBatch(GraphicsDevice);

            _RGB = new Texture2D(this.GraphicsDevice, _tW, _tH);
            _Depth = new Texture2D(this.GraphicsDevice, _tW, _tH);
            
            _textureData = new Color[_tW * _tH];
            _distOffset = _distMax - _distMin;

Здесь же откроем потоки изображений от камеры и сенсора глубины, задав для них настройки. VideoStream и DepthStream являются потомками класса ImageStream, у которого есть метод Open(ImageStreamType streamType, int poolSize, ImageResolution resolution, ImageType image). Для нашей задачи 640х480 — оптимальное разрешение для обоих потоков:

            _runtime.VideoStream.Open(K.ImageStreamType.Video, 2,
                K.ImageResolution.Resolution640x480, K.ImageType.Color);

            _runtime.DepthStream.Open(K.ImageStreamType.Depth, 2,                       
                K.ImageResolution.Resolution640x480, K.ImageType.Depth);

Также здесь мы подпишемся на события, возникающие когда очередной кадр сформирован:

            _runtime.VideoFrameReady +=
                new EventHandler<K.ImageFrameReadyEventArgs>(OnVideoFrameReady);

            _runtime.DepthFrameReady +=
                new EventHandler<K.ImageFrameReadyEventArgs>(OnDepthFrameReady);
        }

В обработчике события готовности видеокадра полученный PlanarImage преобразуется в Texture2D:

        private void OnVideoFrameReady(object sender, K.ImageFrameReadyEventArgs e)
        {
            K.PlanarImage _image = e.ImageFrame.Image;
            _RGB = new Texture2D(this.GraphicsDevice, _tW, _tH);
            
            int index = 0;
            for (int y = 0; y < _image.Height; y++)
            {
                for (int x = 0; x < _image.Width; x++, index += 4)
                {
                    _textureData[y * _image.Width + x] = new Color(
                        _image.Bits[index + 2],
                        _image.Bits[index + 1],
                        _image.Bits[index + 0]);
                }
            }
            _RGB.SetData(_textureData);
        }

В обработчике события готовности кадра глубины информация из PlannarImage адаптируется под указанную нами рабочую зону и помещается в Texture2D. Чем ближе объект, тем светлее он будет на кадре. Белый цвет имеют объекты, расположенные слишком близко или слишком далеко, а так же «мёртвые зоны»:

        private void OnDepthFrameReady(object sender, K.ImageFrameReadyEventArgs e)
        {
            K.PlanarImage p = e.ImageFrame.Image;
            _Depth = new Texture2D(this.GraphicsDevice, _tW, _tH);

            int index = 0;
            int n, distance;
            byte intensity;
            for (int y = 0; y < p.Height; y++)
            {
                for (int x = 0; x < p.Width; x++, index += 2)
                {
                    n = (y * p.Width + x) * 2;
                    distance = (p.Bits[n + 0] | p.Bits[n + 1] << 8);
                    intensity = (byte)(255 - (255 * Math.Max(
                        distance - _distMin, 0) / (_distOffset)));

                    _textureData[y * p.Width + x] =
                        new Color(intensity, intensity, intensity);
                }
            }
            _Depth.SetData(_textureData);
        }

Итак, кадры формируются, теперь остаётся их отрисовать. На полученных кадрах глубины изображение «правильное», тогда как на видеокадрах оно отражено зеркально — мне кажется в данном случае оба изображения должны отражать нас зеркально:

        protected override void Draw(GameTime p_gameTime)
        {
            GraphicsDevice.Clear(Color.CornflowerBlue);

            _spriteBatch.Begin();

            _spriteBatch.Draw(_RGB, Vector2.Zero, Color.White);
            _spriteBatch.Draw(
                _Depth, new Vector2(_tW, 0), nullColor.White,
                0fVector2.Zero, 1fSpriteEffects.FlipHorizontally, 0);

            _spriteBatch.End();

            base.Draw(p_gameTime);
        

И, наконец, то, ради чего мы все здесь сегодня собрались — возможность повертеть этой хреновиной программно :) Теперь мы знаем что в «голову» Kinect встроен акселерометр, однако в SDK BETA нам показывают угол наклона сенсора лишь в вертикальной плоскости (±64 градуса). Добавим метод GetLiftAngle(), получающий это значение:

        private int _liftAngle = 0;

        private int GetLiftAngle()
        {
            _liftAngle = _runtime.NuiCamera.ElevationAngle;
            return _liftAngle;
        }

Свойству ElevationAngle можно назначать значения от -27 до 27, выход за границы этого диапазона сопровождается ArgumentOutOfRangeException. Значению 0 соответствует горизонтальное положение «головы», даже если основание контроллера стоит под наклоном (если сильно наклонить устройство в руках, назначение ElevationAngle приводит к странным углам поворота и жалобному скрежету моторчика). Добавим код для изменения угла наклона с учётом ограничений, не забыв подключить пространство имён System.Threading и поместить назначение ElevationAngle в отдельный поток (иначе приложение будет подвисать на время поворота):

        private void Lift(int p_angle)
        {
            if (p_angle == _liftAngle) return;
            
            if (p_angle > 27) p_angle = 27;
            else if (p_angle < -27) p_angle = -27;
            _liftAngle = p_angle;

            Thread t = new Thread(new ThreadStart(Lift));
            t.Start();
        }

        private void Lift()
        {
            _runtime.NuiCamera.ElevationAngle = _liftAngle;
        }

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

        private KeyboardState _keysState;
        private KeyboardState _keysStatePre;
        
        private float _liftDelta = 0;
        
        protected override void Update(GameTime p_gameTime)
        {
            this.Window.Title = GetLiftAngle().ToString();

            _keysStatePre = _keysState;
            _keysState = Keyboard.GetState();

            if (_keysState.IsKeyDown(Keys.Up))
            {
                _liftDelta += 0.1f;
                this.Window.Title += " +" + ((int)_liftDelta).ToString();
            }
            else if (_keysStatePre.IsKeyDown(Keys.Up))
            {
                Lift(_liftAngle + (int)_liftDelta);
                _liftDelta = 0;
            }

            if (_keysState.IsKeyDown(Keys.Down))
            {
                _liftDelta -= 0.1f;
                this.Window.Title += " " + ((int)_liftDelta).ToString();
            }
            else if (_keysStatePre.IsKeyDown(Keys.Down))
            {
                Lift(_liftAngle + (int)_liftDelta);
                _liftDelta = 0;
            }

            base.Update(p_gameTime);
        }

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

Что если в момент запуска приложения контроллер не будет подключен? А если мы его отключим во время работы? А если передадим в _runtime.Initialize(...) не ту комбинацию параметров? Или попробуем инициализировать Kinect повторно? А если выставим не то разрешение при открытии потоков изображений? Или будем назначать _runtime.NuiCamera.ElevationAngle чаще одного раза в несколько секунд?

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

Для самых терпеливых:

(продолжение следует)
Жду ваших отзывов и замечаний в комментариях и на сайте xnadev.ru

4 комментария:

log.vlad комментирует...

Классный урок! Реально классный. Но вот я до этого с XNA дело не имел и мне составило большим трудом пройти этот урок. Вопрос: почему вы не использовали wpf вместе xna?

andreas комментирует...

Вместе с SDK идёт пример SkeletalViewer с похожей функциональностью, он на WPF. XNA - гораздо более игровая платформа и мне интересно именно такое сочетание

log.vlad комментирует...

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

andreas комментирует...

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

Отправить комментарий