美文网首页CV程序员xbox one
Kinect数据提取与坐标变换

Kinect数据提取与坐标变换

作者: 熊白白 | 来源:发表于2016-07-31 23:07 被阅读6283次

    简述

    Kinect是微软推出的传感器产品,配套Xbox游戏主机,主要针对于家庭娱乐市场。但是微软似乎在搞砸自己产品定位的方面有独特的天赋,虽然销量拼不过PS4,却在科学界大放异彩,以优异的性能和低廉的价格,成为了视觉定位相关研究领域的标配设备。

    kinect

    本文章目的在于从Kinect中提取彩色数据流和深度数据流,并完成两者的坐标变换。因为采集彩色数据和深度数据使用的是两个不同摄像头,所以得到的图像并不完全对应。所以使两者对齐到同一坐标下对后续数据处理非常必要。
    实验使用的设备为Kinect一代产品。开发基于WPF框架,语言为C#。代码参考于Developer Toolkit中C#范例 Color Basics,Depth Basics,Coordinate Mapping Basics部分。

    Sensor对象主体操作

    在C#中使用一个名为KinectSensor的对象描述一台Kinect设备,一般情况下一台PC只可以连接一台Kinect,否则会触发“带宽不足”的错误。
    对Kinect的操作有搜索可用设备,打开设备,接收数据流等操作。

    需要使用的传感器对象的声明
    private KinectSensor sensor;            //传感器对象主体
    
    从设备列表中搜索可用的Kinect
    foreach (var potentialSensor in KinectSensor.KinectSensors)
    {
        if (potentialSensor.Status == KinectStatus.Connected)
        {
            this.sensor = potentialSensor;
            break;
        }
    }
    
    使能流数据并设置格式

    这里,需要使能深度流和彩色流,并设置格式为640x480,Fps=30.

    this.sensor.ColorStream.Enable(ColorImageFormat.RgbResolution640x480Fps30);//使能彩色流并设置模式
    this.sensor.DepthStream.Enable(DepthImageFormat.Resolution640x480Fps30);//使能深度流并设置模式
    
    添加响应事件函数

    以下分别表示颜色流/深度流/所有流就绪的事件处理函数。函数名可自定义,但参数固定。具体见函数定义。这里我们需要得到同步的图像流和深度流,因而仅需要使用所有流就绪的处理函数。当事件发生后,会自动触发相应的函数。

    // this.sensor.ColorFrameReady += this.SensorColorFrameReady;//颜色流
    // this.sensor.DepthFrameReady += this.SensorDepthFrameReady;//深度流
    this.sensor.AllFramesReady += this.SensorAllFramesReady;//所有流
    
    启动设备

    sensor!=null的时候,就可以尝试启动设备

    try
    {
        this.sensor.Start();
    }
    catch (IOException)
    {
        this.sensor = null;
    }
    

    设备启动后,当数据流就绪后,就会触发相应的事件处理函数。

    数据提取

    数据提取在事件处理函数中进行。

    private void SensorAllFramesReady(object sender, AllFramesReadyEventArgs e)
    {
        //..... 函数主体
    }
    
    • 对于彩色数据来说,每像素为8位4通道的BGRA数据。其中第四个通道未使用。因而数据可以直接拷贝到byte[]类型的数组中,用以生成8位4通道的彩色图像来显示。
    • 深度数据的每像素为一个16位short数据,必须存入DepthImagePixel[]类型的数组中,然后可以转存入UInt16[]类型的数组中,用以生成16位的灰度图像来显示。

    当彩色数据和深度数据均就绪后,进入事件处理函数。先检测传感器对象有效性:

    if (null == this.sensor)
        {
            return;//检测有效性
        }
    

    当一帧数据接受之后,我们需要把数据拷贝到特定的像素数组里面加以处理。
    在WPF中提供了专用以动态图像显示的WriteableBitmap类,可由像素数组直接填充。

    对彩色数据的处理

    存储彩色数据的像素数组需要在该函数外声明和定义:

    private byte[] rgb_pix;                 //像素数组,可以从彩色流中读取
    this.rgb_pix = new byte[this.sensor.ColorStream.FramePixelDataLength];//初始化
    

    用像素数组构建彩色位图,用以显示和保存。位图对象的声明和定义:

    private WriteableBitmap rgb_bitmap;     //图像流产生的图像,由rgb_pix像素数组转换得到
    this.rgb_bitmap = new WriteableBitmap(
        this.sensor.ColorStream.FrameWidth, //尺寸(宽)
        this.sensor.ColorStream.FrameHeight,//尺寸(高)
        96.0, 96.0,//横向和纵向分辨率
        PixelFormats.Bgr32,//格式BGRA32位
        null);
    

    对彩色数据的拷贝工作:

    using (ColorImageFrame colorFrame = e.OpenColorImageFrame())//打开图像帧
    {
        //若数据异常,退出函数
        if (colorFrame == null)
            return;
        //保存彩色信息到彩色图像素数组内
        colorFrame.CopyPixelDataTo(this.rgb_pix);
        //用像素数组构建bitmap图像
        this.rgb_bitmap.WritePixels(
             new Int32Rect(0, 0, this.rgb_bitmap.PixelWidth, this.rgb_bitmap.PixelHeight),//尺寸
             this.rgb_pix,//像素数组
             this.rgb_bitmap.PixelWidth * 4,//行字节数,每像素有BGRA四通道四字节。
             0);
        }
    }
    

    上述代码完成了以下工作:

    • 打开图像帧
    • 保存数据到像素数组
    • 构建位图图像

    示例:

    彩色图像示例

    对深度数据的处理

    深度数据的处理类似,不同的是深度数据的格式不同,需要做一些转换工作。
    一个深度信息是16位的带符号short数据,这大大超过了一个8位图像单像素的容纳范围。所以为了便于显示,我们使用了一个16位单通道的灰度图像。因而需要完成:

    • 从设备拷贝数据到深度数组
    • 从深度数组构建像素数组
    • 由像素数组构建灰度图像

    专门存储深度信息的深度数组声明和定义如下:

    private DepthImagePixel[] depthPixels;  //不同于图像流,深度流的数据类型是short型,需要专门的数组来存储
    this.depthPixels = new DepthImagePixel[this.sensor.DepthStream.FramePixelDataLength];
    

    为了图像的显示,需要从深度数组转换到像素数组:
    像素数组的声明和初始化

    private UInt16[] dp_pix;             //深度像素数组。为了生成16位单通道图像,所以才使用了UInt16[]类型的数组
    this.dp_pix = new UInt16[this.sensor.DepthStream.FramePixelDataLength];
    
    

    灰度位图对象的声明和初始化

    private WriteableBitmap dp_bitmap;      //深度图像,由深度图像数组得到
    this.dp_bitmap = new WriteableBitmap(
          this.sensor.ColorStream.FrameWidth,//尺寸(宽)
          this.sensor.ColorStream.FrameHeight, //尺寸(高)
          96.0, 96.0,//横向纵向分辨率
          PixelFormats.Gray16,//像素格式:16位灰度图 
          null);
    

    打开深度数据流,并保存数据

    using (DepthImageFrame depthFrame = e.OpenDepthImageFrame())//打开一帧深度数据
    {
        if (depthFrame == null)
            return;
        // 保存深度信息到特定的深度数组内。注意,深度数据是short类型
        depthFrame.CopyDepthImagePixelDataTo(this.depthPixels);
        for (int i = 0; i < this.depthPixels.Length; ++i)
        {
             // 得到深度数据
             short depth = depthPixels[i].Depth;
             dp_pix[i] = (UInt16)(depth);
        }
        //生成位图图像
        this.dp_bitmap.WritePixels(
              new Int32Rect(0, 0, this.dp_bitmap.PixelWidth, this.dp_bitmap.PixelHeight),//尺寸
              this.dp_pix,//像素数组
              this.dp_bitmap.PixelWidth * 2,//行字节数=行宽*数据字节数
              0);
        }
    

    深度数据是拷贝到特定的数组中去的,而非简单的字节数组。depthPixels的每个元素是一个对象,拥有Depth成员,以存储深度信息。一个深度信息是16位的带符号short数据,范围约正负30000.
    其中,据微软声称,深度数据的“可靠数据范围”为800mm-4000mm。
    示例:

    深度图像示例
    *关于灰度图显示的优化

    对于一个16位灰度图来说,每个像素的数据范围是0-65535,对应颜色为黑色和白色。而Kinect的depth数据通常在6000(6米)以下,所以数据多数投影到了暗色数值,因而显示效果偏暗。为了改进视觉效果,可以把depth数据扩大一个固定的倍数,来作为像素值。实现时请注意数据类型转换,以及数据越界检查。相关工作请读者自行完成。

    坐标对齐

    在做视觉SLAM的时候,从彩色图像中找到一个特征点(X,Y),需要知道它的深度信息。但是彩色图和深度图并不完全对应,所以需要做额外的处理。例如下面的两幅图中,深度图似乎放大了一点。

    彩色图 深度图

    坐标对应

    坐标对应示意图

    彩色图(图1)中的绿点和深度图(图2)中的蓝点,实际对应于物理空间的同一个点。即二者相互对应。而实现坐标变换的第一步,就是把这种对应关系找出来。比如说,我从彩色图像中找到了某个特征点,需要知道它的深度信息,那么我如何找到彩色图上的这个点(rowC,colC)所对应的深度图像上的点(rowD,colD)呢?

    1. 从彩色点到深度点的映射

    SDK中提供了一个函数MapColorFrameToDepthFrame就是用以实现这种投影关系的。它可以生成一个DepthImagePoint[]类型的数组,来存储每个彩色点对应的深度点位置信息。例如:

    //定义格式常量
    private const DepthImageFormat DepthFormat = DepthImageFormat.Resolution640x480Fps30;//深度格式
    private const ColorImageFormat ColorFormat = ColorImageFormat.RgbResolution640x480Fps30;//彩色格式
    //定义用于存储转换结果的坐标数组
    DepthImagePoint[] depthCoordinates;
    depthCoordinates = new DepthImagePoint[this.sensor.DepthStream.FramePixelDataLength];
    //....做处理....
    this.sensor.CoordinateMapper.MapColorFrameToDepthFrame(
         ColorFormat,
         DepthFormat,
         this.depthPixels,
         this.depthCoordinates);
    //得到目标对应值
    //注意C#中序列起始下标为0.图像坐标起始下标也为0
    int pos=rowC*640+rowD;//像素点在一维序列中的位置。
    colD = depthCoordinates[pos].X;//注意X为col值
    rowD = depthCoordinates[pos].Y;//注意Y为row值
    

    这样,得到了(rowC,colC)->(rowD,colD)的映射关系。但是注意,这种映射关系是单向的,这意味着每个彩色点都可以找到对应的深度点,但每个深度点未必可以找到一个彩色点来对应。这在后续的变换深度图中很重要。

    2.从深度点到彩色点的映射

    这小节内容的原理同上小节类似,但所针对的问题是:从深度图像中确定某个点,希望得到它的颜色信息,故需要找到该点在彩色图像中的“映象”。
    函数MapDepthFrameToColorFrame用以实现从深度点到彩色点的投影关系。它可以生成一个ColorImagePoint[]类型的数组,来存储每个深度点对应的彩色点位置信息。例如:

    //定义坐标数组用以存储结果
    ColorImagePoint[] colorCoordinates;
    colorCoordinates = new ColorImagePoint[this.sensor.DepthStream.FramePixelDataLength];
    //....做处理....
    this.sensor.CoordinateMapper.MapDepthFrameToColorFrame(
          DepthFormat,
          this.depthPixels,
          ColorFormat,
          this.colorCoordinates);
    //得到目标的对应值
    //注意C#中序列起始下标为0.图像坐标起始下标也为0
    int pos=rowD*640+colD;//像素点在一维序列中的位置。
    colC = colorCoordinates[pos].X;//注意X为col值
    rowC = colorCoordinates[pos].Y;//注意Y为row值
    

    这样,得到了(rowD,colD)->(rowC,colC)的映射关系。但是注意,这种映射关系同样是单向的,这意味着每个深度点都可以找到对应的彩色点,反之不然。

    坐标变换

    如果需要离线采集数据,那么希望得到这样的一组图像:彩色图A和深度图B,给定某点坐标(X,Y),那么:A(X,Y)为该点彩色信息,B(X,Y)为该点深度信息。换言之,A,B两者完全对应。这样的结果便于保存和后续的处理工作。

    坐标对齐示意图

    通过变换深度图(2)可以得到图(3);通过变换彩色图(1)可以得到图(4)。上图中4张图像中标注的点,实际上对应于物理空间中的同一个点。所以这种变换应该是如下产生的:

    1. 把原始深度图像(2)对齐到彩色图的坐标下,生成图(3)。图(1)(3)可以作为一组结果进行保存,它们的像素是完全对应的。
    2. 把原始彩色图像(1)对齐到深度图的坐标下,生成图(4)。图(2)(4)可以作为一组结果进行保存,它们的像素是完全对应的。

    1. 以彩色图为基准,把深度图对齐到彩色图

    该部分的核心函数为MapColorFrameToDepthFrame,即把深度像素投影到彩色图空间。听到这里一定会让人疑惑,既然是把深度图对齐到彩色图,难道不是从深度图到彩色图投影吗?
    所以接下来是比较生涩难懂的部分,再次贴出示意图:

    变换深度图
    我们的目标是从图2生成图3,所以图3一开始为空,我们需要逐个像素去填充。假设我们需要填充(rowC,colC)位置的像素。因为图1图3必须要完全对应,所以图1(rowC,colC)和图3(rowC,colC)对应的是同一个物理点的颜色和深度信息。怎么去得知这个点的深度信息呢?当然是找到图1(rowC,colC)对应的图2(rowD,colD),然后图3(rowC,colC)由图2(rowD,colD)来填充。图1图2的对应关系就是由MapColorFrameToDepthFrame得到的(rowC,colC)->(rowD,colD)来确定的。
    映射的单向关系

    正是因为这种映射关系是单向的,所以为了将深度图对齐到彩色图,必须是彩色点->深度点的映射,才能保证每个彩色点都可以找到它的“映象”。
    该部分代码依然包含在事件处理函数以内,用以执行坐标对齐操作。

    //定义和初始化dp2_pix[]和dp2_bitmap,用以存储变换后的深度图像素和位图信息。
    private UInt16[] dp2_pix; 
    private WriteableBitmap dp2_bitmap;   
    
    dp2_pix = new UInt16[this.sensor.DepthStream.FramePixelDataLength * sizeof(int)];
    dp2_bitmap = new WriteableBitmap(
         this.sensor.ColorStream.FrameWidth,
         this.sensor.ColorStream.FrameHeight, 
         96.0, 96.0, 
         PixelFormats.Gray16, 
         null);
    
    //坐标映射
    this.sensor.CoordinateMapper.MapColorFrameToDepthFrame(
          ColorFormat,
          DepthFormat,
          this.depthPixels,
          this.depthCoordinates);
    //初始化像素数组。必须用遍历的方式初始化,自带的Initialize()成员函数不好用
    for (int i = 0; i < dp2_pix.Length; i++)
          dp2_pix[i] = 0;
    for (int rowC = 0; rowC < this.dp_bitmap.PixelHeight; rowC++)
    {
          for (int colC = 0; colC < this.dp_bitmap.PixelWidth; colC++)
          {
               //对于深度数组的每个点,找到该点对应于彩色图像上的像素位置,然后把该像素点着色
               int pos = rowC * 640 + colC;//对于某个(X,Y)的像素点来说,它的顺序位置为pos
               int colD = depthCoordinates[pos].X;
               int rowD = depthCoordinates[pos].Y;
               if (colD >= 0 && colD <= 639 && rowD >= 0 && rowD <= 479)
               {
                    dp2_pix[rowC * 640 + colC] = dp_pix[rowD * 640 + colD];
               }
    
          }
    }
    //填充位图图像
    this.dp2_bitmap.WritePixels(
         new Int32Rect(0, 0, this.dp2_bitmap.PixelWidth,this.dp2_bitmap.PixelHeight),
         this.dp2_pix,
         this.dp2_bitmap.PixelWidth * 2,
         0);
    

    2. 以深度图为基准,把彩色图对齐到深度图

    这部分原理和上一节是相同的,所以仅贴出代码:

    //请参考上节自行完成相关变量的定义和初始化
    this.sensor.CoordinateMapper.MapDepthFrameToColorFrame(
           DepthFormat,
           this.depthPixels,
           ColorFormat,
           this.colorCoordinates);
    for (int i = 0; i < rgb2_pix.Length; i++)
          rgb2_pix[i] = 0;//USEFOR to init!!!     
    for (int rowD = 0; rowD < this.dp_bitmap.PixelHeight; rowD++)
    {
           for (int colD = 0; colD < this.dp_bitmap.PixelWidth; colD++)
           {
                 int pos = rowD * this.dp_bitmap.PixelWidth + colD;
                 int colC = colorCoordinates[pos].X;
                 int rowC = colorCoordinates[pos].Y;
                 if (colC >= 0 && colC <= 639 && rowC >= 0 && rowC <= 479)
                 {
                         rgb2_pix[(rowD * 640 + colD) * 4] = rgb_pix[(rowC * 640 + colC) * 4];
                         rgb2_pix[(rowD * 640 + colD) * 4 + 1] = rgb_pix[(rowC * 640 + colC) * 4 + 1];
                         rgb2_pix[(rowD * 640 + colD) * 4 + 2] = rgb_pix[(rowC * 640 + colC) * 4 + 2];
                  }
             }
    }
    this.rgb2_bitmap.WritePixels(
          new Int32Rect(0, 0, this.rgb2_bitmap.PixelWidth, this.rgb2_bitmap.PixelHeight),
          this.rgb2_pix,
          this.rgb2_bitmap.PixelWidth * sizeof(int),
          0);
    

    处理结果

    处理结果 处理结果
    原始彩色图1 原始深度图2
    变换后的深度图3 变换后的彩色图4

    存储数据

    上节中说到,需要存储的数据应该是一组图片,根据需要可以是rgb_bitmapdp2_bitmaprgb2_bitmapdp_bitmap。存储的格式建议为Png文件,经笔者测试,相比于Bmp图像会大大节省存储空间。
    存储时为了避免多线程对同一对象的读写冲突,建议使用互斥锁:

    Object thisLock = new Object();
    lock (thisLock)
    {
        //..处理...
    }
    

    存储WriteableBitmap对象需要一个PngBitmapEncoder对象:

    PngBitmapEncoder encoder_ = new PngBitmapEncoder();
    // 创建编码器并把bitmap载入到编码器中去
    encoder_.Frames.Add(BitmapFrame.Create(this.rgb2_bitmap));   
    using (FileStream fs = new FileStream(@"D:\colorMap" + DateTime.Now.ToString("-HH-mm-ss") + ".png", FileMode.Create))
    {//使用文件流来保存成文件
          encoder_.Save(fs);
    }
    

    小结

    1. 微软的SDK里面提供了众多的Sample,我的代码就是参考它们的。不过这些代码参考于3个不同的Sample,我把它们整合到了一起,并做了注释,理解和分析。
    2. 整个程序是一个不断响应执行的过程,代码主要集中于事件响应函数。所以相关的对象应在函数外声明,在窗体加载函数内初始化,在响应函数内处理。为了叙述方便,我才将这些代码放在一起,实际上它们分散于各处。这种规范请参考微软SDK的Sample。
    3. 经测试发现,如果同时执行两种坐标变换,程序会有明显的卡顿。建议只执行一种。

    相关文章

      网友评论

      • 毕荈:写的太好了,正纠结kinect的坐标变换是怎么回事的,看到这篇文章真是学到了不少知识。
        熊白白:@毕荈 你觉得有用就好

      本文标题:Kinect数据提取与坐标变换

      本文链接:https://www.haomeiwen.com/subject/xrlzjttx.html