简述
Kinect是微软推出的传感器产品,配套Xbox游戏主机,主要针对于家庭娱乐市场。但是微软似乎在搞砸自己产品定位的方面有独特的天赋,虽然销量拼不过PS4,却在科学界大放异彩,以优异的性能和低廉的价格,成为了视觉定位相关研究领域的标配设备。
本文章目的在于从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张图像中标注的点,实际上对应于物理空间中的同一个点。所以这种变换应该是如下产生的:
- 把原始深度图像(2)对齐到彩色图的坐标下,生成图(3)。图(1)(3)可以作为一组结果进行保存,它们的像素是完全对应的。
- 把原始彩色图像(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);
处理结果
处理结果 | 处理结果 |
---|---|
存储数据
上节中说到,需要存储的数据应该是一组图片,根据需要可以是rgb_bitmap
和dp2_bitmap
或rgb2_bitmap
和dp_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);
}
小结
- 微软的SDK里面提供了众多的Sample,我的代码就是参考它们的。不过这些代码参考于3个不同的Sample,我把它们整合到了一起,并做了注释,理解和分析。
- 整个程序是一个不断响应执行的过程,代码主要集中于事件响应函数。所以相关的对象应在函数外声明,在窗体加载函数内初始化,在响应函数内处理。为了叙述方便,我才将这些代码放在一起,实际上它们分散于各处。这种规范请参考微软SDK的Sample。
- 经测试发现,如果同时执行两种坐标变换,程序会有明显的卡顿。建议只执行一种。