Kinect数据提取与坐标变换

简述

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. 经测试发现,如果同时执行两种坐标变换,程序会有明显的卡顿。建议只执行一种。
最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 158,847评论 4 362
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 67,208评论 1 292
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 108,587评论 0 243
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 43,942评论 0 205
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 52,332评论 3 287
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 40,587评论 1 218
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 31,853评论 2 312
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 30,568评论 0 198
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 34,273评论 1 242
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 30,542评论 2 246
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 32,033评论 1 260
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 28,373评论 2 253
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 33,031评论 3 236
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 26,073评论 0 8
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 26,830评论 0 195
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 35,628评论 2 274
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 35,537评论 2 269

推荐阅读更多精彩内容