Kinect数据提取与坐标变换

来源:互联网 发布:sql 短信预警 编辑:程序博客网 时间:2024/05/02 05:07

简述

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.图像坐标起始下标也为0int 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.图像坐标起始下标也为0int 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. 经测试发现,如果同时执行两种坐标变换,程序会有明显的卡顿。建议只执行一种。


文/熊白白(简书作者)
原文链接:http://www.jianshu.com/p/ec8deee8e137
著作权归作者所有,转载请联系作者获得授权,并标注“简书作者”。
0 0
原创粉丝点击