本文详细介绍了Kinect红外传感器,景深数据格式,景深图像的获取与展示,景深图像的增强处理。
1. Kinect传感器
和许多输入设备不一样,Kinect能够产生三维数据,它有红外发射器和摄像头。和其他Kinect SDK如OpenNI或者libfreenect等SDK不同,微软的Kinect SDK没有提供获取原始红外数据流的方法,相反,Kinect SDK从红外摄像头获取的红外数据后,对其进行计算处理,然后产生景深影像数据。景深影像数据从DepthImageFrame产生,它由DepthImageStream对象提供。
DepthImageStream的使用和ColorImageStream的使用类似。DepthImageStream和ColorImageStream都继承自ImageStream。可以像从ColorImageStream获取数据生成图像那样生成景深图像。先看看将景深数据展现出来需要的步骤。下面的步骤和前面显示彩色影像数据相似:
1. 创建一个新的WPF对象。
2. 添加Microsoft.Kinect.dll对象引用。
3. 添加一个Image元素到UI上,将名称改为DepthImage。
4. 添加必要的发现和释放KinectSensor对象的代码。可以参照前面的文章。
5. 修改初始化KinectSensor对象的代码如下:
private void InitializeKinectSensor(KinectSensor kinectSensor)
{
if (kinectSensor != null)
{
DepthImageStream depthStream = kinectSensor.DepthStream;
depthStream.Enable();
depthImageBitMap = new WriteableBitmap(depthStream.FrameWidth, depthStream.FrameHeight, 96,96,PixelFormats.Gray16, null);
depthImageBitmapRect = new Int32Rect(0, 0, depthStream.FrameWidth, depthStream.FrameHeight);
depthImageStride = depthStream.FrameWidth * depthStream.FrameBytesPerPixel;
DepthImage.Source = depthImageBitMap;
kinectSensor.DepthFrameReady += kinectSensor_DepthFrameReady;
kinectSensor.Start();
}
}
6. 修改DepthFrameReady事件,代码如下:
void kinectSensor_DepthFrameReady(object sender, DepthImageFrameReadyEventArgs e)
{
using (DepthImageFrame depthFrame = e.OpenDepthImageFrame())
{
if (depthFrame != null)
{
short[] depthPixelDate = new short[depthFrame.PixelDataLength];
depthFrame.CopyPixelDataTo(depthPixelDate);
depthImageBitMap.WritePixels(depthImageBitmapRect, depthPixelDate, depthImageStride, 0);
}
}
}
运行程序,将会看到如下结果,由于一手需要截图,一手需要站在Kinect前面所以姿势不是很对,有点挫,不过人物的轮廓还是显示出来了,在景深数据中,离Kinect越近,颜色越深,越远,颜色越淡。
2. Kinect 深度测量原理
和其他摄像机一样,近红外摄像机也有视场。Kinect摄像机的视野是有限的,如下图所示:
如图,红外摄像机的视场是金字塔形状的。离摄像机远的物体比近的物体拥有更大的视场横截面积。这意味着影像的高度和宽度,比如640X480和摄像机视场的物理位置并不一一对应。但是每个像素的深度值是和视场中物体离摄像机的距离是对应的。深度帧数据中,每个像素占16位,这样BytesPerPixel属性,即每一个像素占2个字节。每一个像素的深度值只占用了16个位中的13个位。如下图:
获取每一个像素的距离很容易,但是要直接使用还需要做一些位操作。可能大家在实际编程中很少情况会用到位运算。如上图所示,深度值存储在第3至15位中,要获取能够直接使用的深度数据需要向右移位,将游戏者索引(Player Index)位移除。后面将会介绍游戏者索引位的重要性。下面的代码简要描述了如何获取像素的深度值。代码中pixelData变量就是从深度帧数据中获取的short数组。PixelIndex基于待计算像素的位置就算出来的。SDK在DepthImageFrame类中定义了一个常量PlayerIndexBitmaskWidth,它定义了要获取深度数据值需要向右移动的位数。在编写代码时应该使用这一常量而不是硬编码,因为未来随着软硬件水平的提高,Kinect可能会增加能够同时识别人数的个数,从而改变PlayerIndexBitmaskWidth常量的值。
Int32 pixelIndex = (Int32)(p.X + ((Int32)p.Y * frame.Width));
Int32 depth = this.depthPixelDate[pixelIndex] >> DepthImageFrame.PlayerIndexBitmaskWidth;
显示深度数据最简单的方式是将其打印出来。我们要将像素的深度值显示到界面上,当鼠标点击时,显示鼠标点击的位置的像素的深度值。第一步是在主UI界面上添加一个TextBlock:
<Window x:Class="KinectDepthImageDemo.MainWindow" xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation" xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" Title="KinectDepthImage" Height="600" Width="1280" WindowStartupLocation="CenterScreen"> <Grid> <StackPanel Orientation="Horizontal"> <TextBlock x:Name="PixelDepth" FontSize="48" HorizontalAlignment="Left" /> <Image x:Name="DepthImage" Width="640" Height="480" ></Image> </StackPanel> </Grid></Window>
接着我们要处理鼠标点击事件。在添加该事件前,需要首先添加一个私有变量lastDepthFrame来保存每一次DepthFrameReady事件触发时获取到的DepthFrame值。因为我们保存了对最后一个DepthFrame对象的引用,所以事件处理代码不会马上释放该对象。然后,注册DepthFrame 图像控件的MouseLeftButtonUp事件。当用户点击深度图像时,DepthImage_MouseLeftButtonUp事件就会触发,根据鼠标位置获取正确的像素。最后一步将获取到的像素值的深度值显示到界面上,代码如下:
void kinectSensor_DepthFrameReady(object sender, DepthImageFrameReadyEventArgs e)
{
if (lastDepthFrame!=null)
{
lastDepthFrame.Dispose();
lastDepthFrame = null;
}
lastDepthFrame = e.OpenDepthImageFrame();
if (lastDepthFrame != null)
{
depthPixelDate = new short[lastDepthFrame.PixelDataLength];
lastDepthFrame.CopyPixelDataTo(depthPixelDate);
depthImageBitMap.WritePixels(depthImageBitmapRect, depthPixelDate, depthImageStride, 0);
}
}
private void DepthImage_MouseLeftButtonUp(object sender, MouseButtonEventArgs e)
{
Point p = e.GetPosition(DepthImage);
if (depthPixelDate != null && depthPixelDate.Length > 0)
{
Int32 pixelIndex = (Int32)(p.X + ((Int32)p.Y * this.lastDepthFrame.Width));
Int32 depth = this.depthPixelDate[pixelIndex] >> DepthImageFrame.PlayerIndexBitmaskWidth;
Int32 depthInches = (Int32)(depth * 0.0393700787);
Int32 depthFt = depthInches / 12;
depthInches = depthInches % 12;
PixelDepth.Text = String.Format("{0}mm~{1}'{2}", depth, depthFt, depthInches);
}
}
有一点值得注意的是,在UI界面中Image空间的属性中,宽度和高度是硬编码的。如果不设置值,那么空间会随着父容器(From窗体)的大小进行缩放,如果空间的长宽尺寸和深度数据帧的尺寸不一致,当鼠标点击图片时,代码就会返回错误的数据,在某些情况下甚至会抛出异常。像素数组中的数据是固定大小的,它是根据DepthImageStream的Enable方法中的DepthImageFormat参数值来确定的。如果不设置图像控件的大小,那么他就会根据Form窗体的大小进行缩放,这样就需要进行额外的计算,将鼠标的在Form中的位置换算到深度数据帧的维度上。这种缩放和空间转换操作很常见,在后面的文章中我们将会进行讨论,现在为了简单,对图像控件的尺寸进行硬编码。
结果如下图,由于截屏时截不到鼠标符号,所以用红色点代表鼠标位置,下面最左边图片中的红色点位于墙上,该点距离Kinect 2.905米,中间图的点在我的手上,可以看出手离Kinect距离为1.221米,实际距离和这个很相近,可见Kinect的景深数据还是很准确的。
上面最右边图中白色点的深度数据为-1mm。这表示Kinect不能够确定该像素的深度。在处理上数据时,这个值通常是一个特殊值,可以忽略。-1深度值可能是物体离Kinect传感器太近了的缘故。
3. 深度影像增强
在进一步讨论之前,需要会深度值图像进行一些处理。在下面的最左边的图中,灰度级都落在了黑色区域,为了使图像具有更好的灰度级我们需要像之前对彩色数据流图像进行处理那样,对深度值图像进行一些处理。
3.1增强深度影像灰度级
增强深度值图像的最简单方法是按位翻转像素值。图像的颜色是基于深度值的,他们从0开始。在数字光谱中0表示黑色,65536(16位灰阶)表示白色。这意味着下面最左边那幅图中,大部分的值都落在了黑色部分。还有就是所有的不能确定深度值的数据都设置为了0。对位取反操作就会将这些值转换到白色的部分。 作为对比,现在在UI上再添加一个Image控件用来显示处理后的值。
<Window x:Class="KinectDepthImageDemo.MainWindow"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
Title="KinectDepthImage" Height="600" Width="1280" WindowStartupLocation="CenterScreen">
<Grid>
<StackPanel Orientation="Horizontal">
<Image x:Name="DepthImage" Width="640" Height="480" ></Image>
<Image x:Name="EnhancedDepthImage" Width="640" Height="480" />
</StackPanel>
</Grid>
</Window>
下面的代码展示了如何将之前的深度位数据取反获取更好的深度影像数据。该方法在kinectSensor_DepthFrameReady事件中被调用。代码首先创建了一个新的byte数组,然后对这个位数组进行取反操作。注意代码中过滤掉了一些距离太近的点。因为过近的点和过远的点都不准确。所以过滤掉了大于3.5米小于0米的数据,将这些数据设置为白色。
private void CreateLighterShadesOfGray(DepthImageFrame depthFrame, short[] pixelData)
{
Int32 depth;
Int32 loThreashold = 0;
Int32 hiThreshold = 3500;
short[] enhPixelData = new short[depthFrame.Width * depthFrame.Height];
for (int i = 0; i < pixelData.Length; i++)
{
depth = pixelData[i] >> DepthImageFrame.PlayerIndexBitmaskWidth;
if (depth < loThreashold || depth > hiThreshold)
{
enhPixelData[i] = 0xFF;
}
else
{
enhPixelData[i] = (short)~pixelData[i];
}
}
EnhancedDepthImage.Source= BitmapSource.Create(depthFrame.Width, depthFrame.Height, 96, 96, PixelFormats.Gray16, null, enhPixelData, depthFrame.Width * depthFrame.BytesPerPixel);
}
经过处理,图像(上面中间那幅图)的表现力提高了一些,但是如果能够将16位的灰度级用32位彩色表示效果会更好。当 RGB值一样时,就会呈现出灰色。灰度值的范围是0~255,0为黑色,255为白色,之间的颜色为灰色。现在将灰色值以RGB模式展现出来。代码如下:
private void CreateBetterShadesOfGray(DepthImageFrame depthFrame, short[] pixelData)
{
Int32 depth;
Int32 gray;
Int32 loThreashold = 0;
Int32 bytePerPixel = 4;
Int32 hiThreshold = 3500;
byte[] enhPixelData = new byte[depthFrame.Width * depthFrame.Height*bytePerPixel];
for (int i = 0,j=0; i < pixelData.Length; i++,j+=bytePerPixel)
{
depth = pixelData[i] >> DepthImageFrame.PlayerIndexBitmaskWidth;
if (depth < loThreashold || depth > hiThreshold)
{
gray = 0xFF;
}
else
{
gray = (255*depth/0xFFF);
}
enhPixelData[j] = (byte)gray;
enhPixelData[j + 1] = (byte)gray;
enhPixelData[j + 2] = (byte)gray;
}
EnhancedDepthImage.Source = BitmapSource.Create(depthFrame.Width, depthFrame.Height, 96, 96, PixelFormats.Bgr32, null, enhPixelData, depthFrame.Width * bytePerPixel);
}
上面的代码中,将彩色影像的格式改为了Bgr32位,这意味每一个像素占用32位(4个字节)。每一个R,G,B分别占8位,剩余8位留用。这种模式限制了RGB的取值为0-255,所以需要将深度值转换到这一个范围内。除此之外,我们还设置了最小最大的探测范围,这个和之前的一样,任何不在范围内的都设置为白色。将深度值除以4095(0XFFF,深度探测的最大值),然后乘以255,这样就可以将深度数据转换到0至255之间了。运行后效果如上右图所示,可以看出,采用颜色模式显示灰度较之前采用灰度模式显示能够显示更多的细节信息。
3.2 深度数据的彩色渲染
将深度数据值转化到0-255并用RGB模式进行显示可以起到增强图像的效果,能够从图像上直观的看出更多的深度细节信息。还有另外一种简单,效果也不错的方法,那就是将深度数据值转换为色调和饱和度并用图像予以显示。下面的代码展示了这一实现:
private void CreateColorDepthImage(DepthImageFrame depthFrame, short[] pixelData)
{
Int32 depth;
Double hue;
Int32 loThreshold = 1200;
Int32 hiThreshold = 3500;
Int32 bytesPerPixel = 4;
byte[] rgb = new byte[3];
byte[] enhPixelData = new byte[depthFrame.Width * depthFrame.Height * bytesPerPixel];
for (int i = 0, j = 0; i < pixelData.Length; i++, j += bytesPerPixel)
{
depth = pixelData[i] >> DepthImageFrame.PlayerIndexBitmaskWidth;
if (depth < loThreshold || depth > hiThreshold)
{
enhPixelData[j] = 0x00;
enhPixelData[j + 1] = 0x00;
enhPixelData[j + 2] = 0x00;
}
else
{
hue = ((360 * depth / 0xFFF) + loThreshold);
ConvertHslToRgb(hue, 100, 100, rgb);
enhPixelData[j] = rgb[2]; //Blue
enhPixelData[j + 1] = rgb[1]; //Green
enhPixelData[j + 2] = rgb[0]; //Red
}
}
EnhancedDepthImage.Source = BitmapSource.Create(depthFrame.Width, depthFrame.Height, 96, 96, PixelFormats.Bgr32, null, enhPixelData, depthFrame.Width * bytesPerPixel);
}
以上代码中使用了ConvertHslToRgb这一函数,该函数的作用是进行两个颜色空间的转换,就是将H(Hue色调)S(Saturation饱和度)L(Light亮度)颜色空间转换到RGB颜色空间的函数。之前学过遥感图像处理,所以对这两个颜色空间比较熟悉。转化的代码如下:
public void ConvertHslToRgb(Double hue, Double saturation, Double lightness, byte[] rgb)
{
Double red = 0.0;
Double green = 0.0;
Double blue = 0.0;
hue = hue % 360.0;
saturation = saturation / 100.0;
lightness = lightness / 100.0;
if (saturation == 0.0)
{
red = lightness;
green = lightness;
blue = lightness;
}
else
{
Double huePrime = hue / 60.0;
Int32 x = (Int32)huePrime;
Double xPrime = huePrime - (Double)x;
Double L0 = lightness * (1.0 - saturation);
Double L1 = lightness * (1.0 - (saturation * xPrime));
Double L2 = lightness * (1.0 - (saturation * (1.0 - xPrime)));
switch (x)
{
case 0:
red = lightness;
green = L2;
blue = L0;
break;
case 1:
red = L1;
green = lightness;
blue = L0;
break;
case 2:
red = L0;
green = lightness;
blue = L2;
break;
case 3:
red = L0;
green = L1;
blue = lightness;
break;
case 4:
red = L2;
green = L0;
blue = lightness;
break;
case 5:
red = lightness;
green = L0;
blue = L1;
break;
}
}
rgb[0] = (byte)(255.0 * red);
rgb[1] = (byte)(255.0 * green);
rgb[2] = (byte)(255.0 * blue);
}
运行程序,会得到如下右图结果(为了对比,下面左边第一幅图是原始数据,第二幅图是使用RGB模式显示深度数据)。最右边图中,离摄像头近的呈蓝色,然后由近至远颜色从蓝色变为紫色,最远的呈红色。图中,我手上托着截图用的键盘,所以可以看到,床离摄像头最近,呈蓝色,键盘比人体里摄像头更近,呈谈蓝色,人体各部分里摄像头的距离也不一样,胸、腹、头部离摄像头更近。后面的墙离摄像头最远,呈橙色至红色。
运行上面的程序会发现很卡,我好不容易才截到这张图,这是因为在将HUL空间向颜色空间转换需要对640*480=307200个像素逐个进行运算,并且运算中有小数,除法等操作。该计算操作和UI线程位于同一线程内,会阻塞UI线程更新界面。更好的做法是将这一运算操作放在background线程中。每一次当KinectSensor触发frame-ready事件时,代码顺序存储彩色影像。转换完成后,backgroud线程使用WPF中的Dispatcher来更新UI线程中Image对象的数据源。上一篇文章中以及讲过这一问题,这种异步的操作在基于Kinect开发的应用中很常见,因为获取深度数据是一个很频繁的操作。如果将获取数据以及对数据进行处理放在主UI线程中就会使得程序变得很慢,甚至不能响应用户的操作,这降低了用户体验。
4. 结语
本文介绍了Kinect红外摄像头产生的深度影像数据流,KinectSensor探测深度的原理,如何获取像素点的深度值,深度数据的可视化以及一些简单的增强处理。
限于篇幅原因,下一篇文章将会介绍Kinect景深数据影像处理,以及在本文第2节中所景深数据格式中没有讲到的游戏者索引位(Player Index),最后将会介绍KinectSensor红外传感器如何结合游戏者索引位获取人物的空间范围,包括人物的宽度,高度等信息。