Archives du blog

Présentation du SDK Kinect – Partie 3

Ceci est la troisième partie d’une série d’articles, si vous avez raté la partie 2, c’est ici !

Mise en place de la récupération des distances

Maintenant que nous avons vu comment récupérer les images capturées par le capteur couleur du Kinect, il est temps de passer à des choses un peu plus sympas ! De façon globale, la récupération des informations de profondeur est quasi identique à la récupération de la vidéo couleur. La chose principale qui change est le type de donnée que l’on récupère où en tout cas ce qu’elle représente. En effet, pour l’image couleur, on a vu que Kinect renvoie gentiment un tableau de Byte qui représente clairement une image. Dans le cas de la profondeur (récupérée via les capteurs infrarouges, je le rappelle), on va recevoir à nouveau un tableau de Byte (du binaire donc) mais celui-ci ne va pas représenter une image mais un tableau de distances. Concrètement, chaque case du tableau représente à quel distance le point représenté par le pixel est par rapport au capteur.
Pourquoi ne récupère-t-on pas directement un tableau de Byte qui représente l’image de profondeur ? Parce que cela va nous permettre de construire notre propre image telle qu’on la souhaite, nous allons voir ça juste après.

Bien, après ce petit interlude théorique, passons à la pratique. La première chose à faire est de modifier l’initialisation du Runtime pour que Kinect prévienne lorsque l’on peut récupérer des informations de profondeur. Deux choix s’offrent à nous:

_nui.Initialize(RuntimeOptions.UseColor | RuntimeOptions.UseDepth);

ou

_nui.Initialize(RuntimeOptions.UseColor | RuntimeOptions.UseDepthAndPlayerIndex | RuntimeOptions.UseSkeletalTracking);

Les noms sont, je pense, assez clairs, RunTimeOptions.UseDepth permet de récupérer uniquement l’information sur la distance d’un pixel alors que RunTimeOptions.UseDepthAndPlayerIndex permet également de savoir si ce pixel fait partie d’un joueur ou non et le cas échéant de savoir lequel. A noter qu’il faut que Kinect trouve votre squelette pour savoir que vous êtes un joueur et donc fournir correctement cette information (la reconnaissance de squelette est quasi immédiate pour peu que votre corps entier soit présent dans le champs de la camera, plus d’informations dans la partie suivante). C’est pour cela que j’ai aussi ajouté RuntimeOptions.UseSkeletalTracking.
Nous allons ici utiliser la profondeur avec information sur le joueur.
Maintenant que ceci est fait, il suffit de faire grosso modo la même chose que pour la caméra couleur en commençant par ouvrir le flux pour la profondeur.

_nui.DepthStream.Open(ImageStreamType.Depth, 2, ImageResolution.Resolution320x240, ImageType.DepthAndPlayerIndex);

Ensuite on s’abonne à l’événement DepthFrameReady du Runtime qui sera lui aussi déclenché environ 30 fois pas seconde.

_nui.DepthFrameReady += nui_DepthFrameReady;

On va alors pouvoir récupérer l’objet PlanarImage de l’argument de type ImageFrameReadyEventArgs.

void nui_DepthFrameReady(object sender, ImageFrameReadyEventArgs e)
{
    PlanarImage profondeurImage = e.ImageFrame.Image;
}

Récupérer les distances à partir du tableau d’octets

C’est donc ici que la subtilité commence ! Comme je l’ai dit plus haut PlanarImage n’est pas une image en soit, il s’agit plutôt d’un tableau de Byte qui va nous permettre de connaitre à quelle distance du capteur se situe le point représenté par un pixel. Chaque pixel contient une distance qui est enregistrée sur 2 octets. Le schéma ci-dessous représente le contenu de ces deux octets en fonction du type de donnée de profondeur choisie dans l’initialisation (Depth tout seul ou Depth & PlayerIndex).

Schéma d'oganisation des octets

Détail de composition des octets pour la distance

A noter que les données entrées dans ce schéma ne sont ABSOLUMENT pas représentatives de ce qui pourrait arriver.
Maintenant que ceci est dit, on va pouvoir faire notre méthode qui va nous retourner la distance pour un pixel donné ! Il faut tout de même avant ça savoir que la distance est la combinaison des deux octets et que l’octet ayant l’indice le plus grand dans le tableau du PlanarImage est l’octet qui a le poids le plus fort (donc qui doit être le plus à gauche).
Il va donc être nécessaire de faire un « déplacement de bit », bitshift en anglais qui va nous permettre de replacer le tout à un endroit cohérent et récupérer la distance. Voici la méthode qui permet de retourner la distance en millimètre d’un pixel sans le player index:

private int GetDistance(byte byte1, byte byte2)
{
    int distance = (int)(byte1 | byte2 << 8);
    return distance; 
}

On voit bien ici que l’on décale de 8 bits vers la gauche (donc d’un octet entier) le deuxième octet avant de combiner les deux de façon à retrouver un entier codé sur 2 octets cohérents.
Voici la même méthode mais cette fois-ci en prenant en compte le cas où l’on a le player index d’indiqué dans les octets:

private int GetDistanceWithPlayerIndex(byte byte1, byte byte2)
{
    int distance = (int)(byte1 >> 3 | byte2 << 5);
    return distance; 
}

On décale le premier octet de 3 bits vers la droite pour faire sortir le PlayerIndex de l’octet puis on lui colle devant le deuxième octet (un décalage de 5 vers la gauche suffit puisque 3 bits ont été enlevé sur le premier).
Et voila ! nous sommes prêt à créer une image personnelle qui représentera ces distances récupérées par Kinect.

Construire une depth map (image de profondeur)

Pour cela, il faut savoir que Kinect peut récupérer les distances dans un spectre allant de 850mm à 4000mm, toute distance de 0 signifie que le capteur ne connait pas l’information (peut être causé par une distance trop proche, une réflectivité trop importante, …).
Je vous donne la méthode qui créé l’image en binaire à partir du tableau de byte des distances :

private byte[] CreateImageFromDistances(PlanarImage distanceImage)
{
    // On récupère la largeur et la hauteur de "l'image" des distances (ici normalement 320x240)
    int width = distanceImage.Width;
    int height = distanceImage.Height;

    // On créé le tableau de byte qui contiendra notre image réelle en binaire que l'on affichera plus tard
    byte[] imageCreeeBinaire = new byte[width*height];
    // On créé l'index qui permettra de savoir où l'on est de la création.
    int positionCreation = 0;

    // On définit les bornes de visibilité de notre capteur pour le calcul de la couleur plus tard
    const int minDistance = 850;
    const int maxDistance = 4000;

    // On parcourt tout le tableau d'octet des distances par saut de 2
    for (int i = 0; i < distanceImage.Bits.Length - 1; i += 2)
    {
        // On récupère la distance du pixel en cours de traitement
        int distance = GetDistanceWithPlayerIndex(distanceImage.Bits[i], distanceImage.Bits[i + 1]);

        // On calcule le poids de la couleur (de 0 à 255) en fonction de la distance
        imageCreeeBinaire[positionCreation] = (byte)(255 * Math.Max(distance - minDistance, 0) / maxDistance);

        // On incrémente la position de la création de l'image
        positionCreation++;
     }

     // Enfin, on retourne l'image binaire créée
     return imageCreeeBinaire;

J’ai pas mal commenté ce code pour qu’il soit plus facile à comprendre. Globalement on parcourt tout le tableau d’octets que nous fournit le capteur par pas de deux (puisque une distance est sur 2 octets) et en fonction de la distance du pixel traité, on ajoute un octet à notre image finale. A noter que notre image finale sera un dégradé de gris, donc un seul octet suffit par pixel (plus la valeur est élevée plus c’est blanc, inversement plus elle est faible plus c’est noir). Dans notre cas donc, plus le pixel de distance représentera quelque chose de prêt, plus le pixel de notre image finale sera noir.

Et voila ! Il ne reste plus qu’à appliquer le tableau d’octets donné par cette méthode en tant que source d’un contrôle WPF Image que je vous laisse le soin de créer.

void nui_DepthFrameReady(object sender, ImageFrameReadyEventArgs e)
{
    // On récupère "l'image" prise par le capteur infrarouge
    PlanarImage imageProfondeur = e.ImageFrame.Image;

    // On créé l'image binaire en échelle de gris à partir des informations de distance
    byte[] imageBinaire = CreateImageFromDistances(e.ImageFrame.Image);

    // Puis tout comme avec la couleur, on définit la source pour le contrôle WPF
    imageDepth.Source = BitmapSource.Create(
        imageProfondeur.Width, imageProfondeur.Height, 96, 96, PixelFormats.Gray8,
        null, imageBinaire, imageProfondeur.Width);
}

Et voici le résultat au lancement de l’application.

Screenshot du projet après depth map

Le projet avec la depth map

Comme vous pouvez le voir plus les objets sont loin du capteur plus ils sont blancs et c’est ce que l’on voulait !

Pour aller plus loin

On a vu comment afficher une image qui représente la distance des objets par rapport au capteur Kinect mais on n’a fait aucune utilisation du PlayerIndex qui est aussi disponible pour peu que l’initialisation du Runtime et l’ouverture du flux soient bien faites.
Le PlayerIndex (qui se récupère, je le rappelle, sur le premier octet des données de distance pour un pixel) peut prendre 6 valeurs différentes :

  • 0 signifie que le pixel concerné ne correspond pas à un joueur
  • 1 signifie que le pixel concerné correspond au squelette 0
  • 2 signifie que le pixel concerné correspond au squelette 1
  • etc..

En théorie il est possible de détecter 5 joueurs différents avec les données de profondeur (par contre seulement 2 squelettes), je n’ai personnellement pu tester qu’avec 3.
En faisant référence au schéma des données pour la distance et le PlayerIndex, on peut en déduire cette méthode pour retrouver le PlayerIndex:

private int GetPlayerIndex(byte octet)
{
    return (int)(octet & 7);
}

En effet, la représentation de 7 en binaire est 00000111, ce qui signifie que l’opération & mettra à 0 tous les bits qui ne concernent pas le PlayerIndex et nous retournera ainsi la bonne information.
Pour finir voici la méthode CreateImageFromDistances modifiée de façon à ce que les joueurs apparaissent en couleur lorsqu’ils sont détectés. La plus grosse différence est le type de données que l’on retourne, notre image finale n’aura plus besoin d’un octet par pixel mais de trois qui définissent chacun la quantité d’une couleur de base (rouge, vert et bleu).

private byte[] CreateImageFromDistances(PlanarImage distanceImage)
{
    // On récupère la largeur et la hauteur de "l'image" des distances (ici normalement 320x240)
    int width = distanceImage.Width;
    int height = distanceImage.Height;

    // On créé le tableau de byte qui contiendra notre image réelle en binaire que l'on affichera plus tard
    byte[] imageCreeeBinaire = new byte[width*height*3];
    // On créé l'index qui permettra de savoir où l'on est de la création.
    int positionCreation = 0;

    // On définit les bornes de visibilité de notre capteur pour le calcul de la couleur plus tard
    const int minDistance = 850;
    const int maxDistance = 4000;

    // On parcourt tout le tableau d'octet des distances par saut de 2
    for (int i = 0; i < distanceImage.Bits.Length - 1; i += 2)
    {
        // On regarde si le pixel appartient à un joueur
        if (GetPlayerIndex(distanceImage.Bits[i]) != 0)
        {
            // Si c'est le cas, on colorie le joueur en fonction de son numero
            switch (GetPlayerIndex(distanceImage.Bits[i]))
            {
                // Si c'est le squelette 0
                case 1:
                    // On met le rouge au max, les autres couleurs à 0
                    imageCreeeBinaire[positionCreation] = 255;
                    imageCreeeBinaire[positionCreation + 1] = 0;
                    imageCreeeBinaire[positionCreation + 2] = 0;
                    break;
                // Si c'est le squelette 1
                case 2:
                    // On met le vert au max, les autres couleurs à 0
                    imageCreeeBinaire[positionCreation] = 0;
                    imageCreeeBinaire[positionCreation + 1] = 255;
                    imageCreeeBinaire[positionCreation + 2] = 0;
                    break;
                // Si c'est le squelette 2
                case 3:
                    // On met le bleu au max, les autres couleurs à 0
                    imageCreeeBinaire[positionCreation] = 0;
                    imageCreeeBinaire[positionCreation + 1] = 0;
                    imageCreeeBinaire[positionCreation + 2] = 255;
                    break;
                default:
                    imageCreeeBinaire[positionCreation] = 255;
                    imageCreeeBinaire[positionCreation + 1] = 0;
                    imageCreeeBinaire[positionCreation + 2] = 255;
                    break;
            }
        }
        // Sinon, on colorie en echelle de gris en fonction de la distance
        else
        {
            // On récupère la distance du pixel en cours de traitement
            int distance = GetDistanceWithPlayerIndex(distanceImage.Bits[i], distanceImage.Bits[i + 1]);

            // On calcule le poids de la couleur (de 0 à 255) en fonction de la distance et on l'applique aux 3 couleurs pour obtenir du gris
            byte grayIntensity = (byte)(255 * Math.Max(distance - minDistance, 0) / maxDistance);
            imageCreeeBinaire[positionCreation] = grayIntensity;
            imageCreeeBinaire[positionCreation + 1] = grayIntensity;
            imageCreeeBinaire[positionCreation + 2] = grayIntensity;
        }
        // On incrémente de 4 la position de la création de l'image (les 3 couleurs + un octet vide pour respecter le format de pixel)
        positionCreation+=3;
    }

    // Enfin, on retourne l'image binaire créée
    return imageCreeeBinaire;
}

Ne pas oublier également de modifier le BitmapSource.Create du contrôle WPF.

imageDepth.Source = BitmapSource.Create(
    imageProfondeur.Width, imageProfondeur.Height, 96, 96, PixelFormats.Bgr24,
    null, imageBinaire, imageProfondeur.Width * 3);

Et voila, le tour est joué lorsque vous êtes entièrement visible par le capteur et que votre squelette est repéré, votre silhouette apparait colorée !
Ceci montre bien qu’il est très pratique de récupérer les données de distance plutôt qu’une image toute prête à l’emploi puisque l’on peut assez facilement créer sa propre depth map en fonction de ses besoins.

Ce long article est terminé ! Rendez-vous dans le 4ème article de la série pour découvrir le skeleton tracking et tout ce que qui en découle !

Présentation du SDK Kinect – Partie 2

Ceci est la deuxième partie de la série d’article d’introduction au SDK Kinect. Si vous avez raté la première partie, c’est par là.

Mise en place du projet pour Kinect

Dans la première partie, nous avons vu comment installer tous les outils nécessaires au développement avec Kinect, il est donc grand temps de nous y mettre pour de vrai dès maintenant.
Pour les démonstrations du développement, j’utiliserai un projet WPF, je vous laisse le soin de le créer par vous même. Voici donc la vue de mon espace de travail juste après la création du projet.

Projet wpf à l'origine

Juste après création du projet

Vous vous en doutez, nous allons avoir besoin d’ajouter des références à notre projet pour pouvoir utiliser les fonctionnalités du SDK, la référence à ajouter est Microsoft.Research.Kinect. Cette librairie est normalement présente dans le GAC après l’installation donc vous la trouverez directement dans l’onglet .NET de l’ajout de référence. Une fois que ceci est fait, vous remarquerez que 2 namespaces sont ajoutables depuis cette librairie:

using Microsoft.Research.Kinect.Nui;

et

using Microsoft.Research.Kinect.Audio;

Pour cette article, nous n’aurons besoin que du namespace nui, qui signifie Natural User Interface et va nous permettre de récupérer les flux vidéos depuis Kinect.

La toute première chose à faire lorsque l’on va utiliser les fonctionnalités du Kinect est d’initialiser le runtime. Qui dit initialisation dit aussi dés-initialisation afin de libérer Kinect pour éviter tout conflit lors d’une utilisation ultérieure.
Je vous conseil de vous abonner à l’évènement Loaded de la fenêtre principale et de faire l’initialisation du runtime dans la méthode associée plutôt que de le faire directement dans le constructeur de la fenêtre. A l’inverse, la dés-initialisation devrait être faite lorsque la fenêtre se ferme, donc à l’appel de l’événement Closed.
Ainsi la structure de base de tout projet utilisant Kinect devrait ressembler à ça:

public partial class MainWindow : Window
{
    // L'attribut de type Runtime qui sera le point d'entrée à tout interaction avec Kinect
    private Runtime _nui = new Runtime();

    public MainWindow()
    {
        InitializeComponent();

        // On s'abonne à l'évènement de fin de chargement pour aller initialiser le runtime
        Loaded += MainWindow_Loaded;

        // Même chose avec l'évènement de fermeture et pour la dés-initialisation
        Closed += MainWindow_Closed;
    }

    void MainWindow_Loaded(object sender, RoutedEventArgs e)
    {
        try
        {
            // On essaye d'initialiser le runtime en spécifiant les informations qui nous intéressent
            _nui.Initialize(RuntimeOptions.UseColor);
        }
        catch (InvalidOperationException)
        {
            // Si on ne peut initialiser le Kinect, on affiche un message d'erreur
            MessageBox.Show("Erreur lors de l'initialisation du runtime, vérifier que le Kinect est bien branché.");
            return;
        }
    }

    void MainWindow_Closed(object sender, EventArgs e)
    {
        _nui.Uninitialize(RuntimeOptions.UseColor);
    }
}

Je pense que le code n’est pas très compliqué à comprendre, on créé un attribut de type Runtime qui va nous permettre toute interaction avec Kinect, puis on l’initialise en spécifiant quelles informations on va souhaiter récupérer par la suite (image couleur, données de profondeur, tracking de squelette, etc..). Les informations passées en argument de Initialize peuvent être cumulées avec des |, par exemple:

_nui.Initialize(RuntimeOptions.UseColor | RuntimeOptions.UseDepth);

Enfin, on arrête l’utilisation du Kinect lorsque la fenêtre est fermée avec la dés-initialisation du runtime.
Vous pouvez essayer de lancer dès maintenant l’application et si vous n’avez aucune MessageBox qui apparait, alors vous êtes prêt à récupérer les informations du Kinect !
Nous allons d’ailleurs commencer tout de suite par la récupération de l’image RGB fournie par le capteur.

Récupération de l’image couleur du capteur Kinect

Maintenant que que nous avons initialisé le runtime du Kinect, nous allons pouvoir récupérer les informations fournies par celui-ci. Pour cela, le SDK nous fournit un flux que l’on peut récupérer et lire à notre guise (à condition de l’avoir spécifié en argument de Initialize).

try
{
    // On ouvre le flux vidéo pour pouvoir récupérer les images de la caméra RGB
    _nui.VideoStream.Open(ImageStreamType.Video, 2, ImageResolution.Resolution640x480, ImageType.Color);
}
catch (InvalidOperationException)
{
    // Si le flux n'a pu s'ouvrir, on le signal
    MessageBox.Show("Impossible d'ouvrir les flux, vérifiez que vous tentez d'ouvrir un flux à un format et une taille prise en charge");
    return;
}

On tente ici d’ouvrir le flux vidéo en passant en argument les informations du flux que l’on souhaite récupérer. Le premier argument correspond au type de flux que l’on veut avoir, ici le flux vidéo, le deuxième argument est le nombre de tampon qui va être utilisé (la valeur 2 est celle par défaut et permet d’avoir l’image actuelle ainsi qu’une image d’avance). Ensuite on spécifie la résolution de l’image que l’on souhaite récupérer, il est possible d’obtenir toutes les résolutions utilisables à l’aide de la méthode ImageStream.GetValidResolutions(ImageType). Enfin, le dernier argument permet de dire sous quelle format on souhaite récupérer le flux, ici sous forme d’image couleur.

Très bien ! Maintenant que le flux est ouvert, un événement est déclenché environ 30 fois par seconde permettant de récupérer le flux produit, celui-ci s’appelle VideoFrameReady. Il va donc falloir s’y abonner pour récupérer son objet de type ImageFrameReadyEventArgs qui lui même contient une PlanarImage représentant l’image récupérée. Le SDK n’étant pas associé à une plateforme particulière(WPF / Winforms/ …), il contient sa propre classe de définition d’une image avec justement cette classe PlanarImage.
Voici donc en code ce qui est dit juste avant:

// On s'abonne à l'événement déclenché dès qu'une image est prête à être traitée (environ 30 fois pas seconde)
_nui.VideoFrameReady += nui_VideoFrameReady;
void nui_VideoFrameReady(object sender, ImageFrameReadyEventArgs e)
{

}

Puisque PlanarImage ne représente pas une image en WPF, nous allons créer un contrôle WPF de type Image qui sera notre hôte pour l’image couleur du Kinect.

<Window x:Class="IntroductionSDKKinect.MainWindow"
        xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
        xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
        Title="MainWindow" Height="400" Width="1000">
    <Grid>
        <Image Name="imageRGB" Width="320" Height="240" HorizontalAlignment="Left" Margin="20,0,0,0"/>
    </Grid>
</Window>

Il ne nous reste plus qu’à transformer l’image récupérée par Kinect en une source de données affichable par le contrôle WPF.

void nui_VideoFrameReady(object sender, ImageFrameReadyEventArgs e)
{
   // On récupère l'image fournie par Kinect
   PlanarImage imageFromKinect = e.ImageFrame.Image;

   // On définit la source du contrôle Image WPF en utilisant l'image récupérée
   imageRGB.Source = BitmapSource.Create(imageFromKinect.Width, imageFromKinect.Height, 96, 96, PixelFormats.Bgr32,
                                         null, imageFromKinect.Bits, imageFromKinect.Width * imageFromKinect.BytesPerPixel);
}

Je ne vais pas m’étaler sur les paramètres de la méthode BitmapSource.Create mais si vous souhaitez plus d’informations, vous pouvez consulter la page msdn.
Vous pouvez sans plus attendre lancer votre application et vous admirer à travers la camera du Kinect 😀 .

La scene vue en couleur par Kinect

La vue de la Kinect en couleur.

Ceci clôture cette deuxième partie, rendez-vous donc dans la troisième pour traiter la camera de profondeur (qui utilise l’infrarouge).