1. 性能改进
上文的代码中,对于每一个彩色图像帧,都会创建一个新的Bitmap对象。由于Kinect视频摄像头默认采集频率为每秒30幅,所以应用程序每秒会创建30个bitmap对象,产生30次的Bitmap内存创建,对象初始化,填充像素数据等操作。这些对象很快就会变成垃圾等待垃圾回收器进行回收。对数据量小的程序来说可能影响不是很明显,但当数据量很大时,其缺点就会显现出来。
改进方法是使用WriteableBitmap对象。它位于System.Windows.Media.Imaging命名空间下面,该对象被用来处理需要频繁更新的像素数据。当创建WriteableBitmap时,应用程序需要指定它的高度,宽度以及格式,以使得能够一次性为WriteableBitmap创建好内存,以后只需根据需要更新像素即可。
使用WriteableBitmap代码改动地方很小。下面的代码中,首先定义三个新的成员变量,一个是实际的WriteableBitmap对象,另外两个用来更新像素数据。每一幅图像的大小都是不变的,因此在创建WriteableBitmap时只需计算一次即可。
InitializeKinect方法中加粗的部分是更改的代码。创建WriteableBitmap对象,准备接收像素数据,图像的范围同时也计算了。在初始化WriteableBitmap的时候,同时也绑定了UI元素(名为ColorImageElement的Image对象)。此时WriteableBitmap中没有像素数据,所以UI上是空的。
private WriteableBitmap colorImageBitmap; private Int32Rect colorImageBitmapRect; private int colorImageStride; private byte[] colorImagePixelData; if (kinectSensor != null) { ColorImageStream colorStream=kinectSensor.ColorStream; colorStream.Enable(); this.colorImageBitMap = new WriteableBitmap(colorStream.FrameWidth, colorStream.FrameHeight, 96, 96, PixelFormats.Bgr32, null); this.colorImageBitmapRect = new Int32Rect(0, 0, colorStream.FrameWidth, colorStream.FrameHeight); this.colorImageStride = colorStream.FrameWidth * colorStream.FrameBytesPerPixel; ColorImageElement.Source = this.colorImageBitMap; kinectSensor.ColorFrameReady += kinectSensor_ColorFrameReady; kinectSensor.Start(); }
还需要进行的一处改动是,对ColorFrameReady事件响应的代码。如下图。首先删除之前创建Bitmap那部分的代码。调用WriteableBitmap对象的WritePixels方法来更新图像。方法使用图像的矩形范围,代码像素数据的数组,图像的Stride,以及偏移(offset).偏移量通常设置为0。
private void Kinect_ColorFrameReady(object sender, ColorImageFrameReadyEventArgs e) { using (ColorImageFrame frame = e.OpenColorImageFrame()) { if (frame != null) { byte[] pixelData = new byte[frame.PixelDataLength]; frame.CopyPixelDataTo(pixelData); this.colorImageBitmap.WritePixels(this.colorImageBitmapRect, pixelData, this.colorImageStride, 0); } } }
基于Kinect的应用程序在无论是在显示ColorImageStream数据还是显示DepthImageStream数据的时候,都应该使用WriteableBitmap对象来显示帧影像。在最好的情况下,彩色数据流会每秒产生30帧彩色影像,这意味着对内存资源的消耗比较大。WriteableBitmap能够减少这种内存消耗,减少需要更新影响带来的内存开辟和回收操作。毕竟在应用中显示帧数据不是应用程序的最主要功能,所以在这方面减少内像存消耗显得很有必要。
2. 简单的图像处理
每一帧ColorImageFrame都是以字节序列的方式返回原始的像素数据。应用程序必须以这些数据创建图像。这意味这我们可以对这些原始数据进行一定的处理,然后再展示出来。下面来看看如何对获取的原始数据进行一些简单的处理。
void kinectSensor_ColorFrameReady(object sender, ColorImageFrameReadyEventArgs e) { using (ColorImageFrame frame = e.OpenColorImageFrame()) { if (frame != null) { byte[] pixelData = new byte[frame.PixelDataLength]; frame.CopyPixelDataTo(pixelData); for (int i = 0; i < pixelData.Length; i += frame.BytesPerPixel) { pixelData[i] = 0x00;//蓝色 pixelData[i + 1] = 0x00;//绿色 } this.colorImageBitMap.WritePixels(this.colorImageBitmapRect, pixelData,this.colorImageStride,0); } } }
以上的实验关闭了每个像素点的蓝色和绿色通道。for循环遍历每个像素,使得i的起始位置重视该像素的第一个字节。由于数据的格式是Bgr32,即RGB32位(一个像素共占4个字节,每个字节8位),所以第一个字节是蓝色通道,第二个是绿色,第三个是红色。循环体类,将第一个和第二个通道设置为0.所以输出的代码中只用红色通道的信息。这是最基本的图像处理。
代码中对像素的操作和像素着色函数相识,可以通过很复杂的算法来进行。大家可以试试对这些像素赋予一些其它的值然后再查看图像的显示结果。这类操作通常很消耗计算资源。像素着色通常是GPU上的一些很基础的操作。下面有一些简单的算法用来对像素进行处理。
- Inverted Color
pixelData[i]=(byte)~pixelData[i];
pixelData[i+1]=(byte)~pixelData[i+1];
pixelData[i+2]=(byte)~pixelData[i+2];
- Apocalyptic Zombie
pixelData[i]= pixelData[i+1];
pixelData[i+1]= pixelData[i];
pixelData[i+2]=(byte)~pixelData[i+2];
- Gray scale
byte gray=Math.Max(pixelData[i],pixelData[i+1])
gray=Math.Max(gray,pixelData[i+2]);
pixelData[i]=gray;
pixelData[i+1]=gray;
pixelData[i+2]=gray;
- Grainy black and white movie
byte gray=Math.Min(pixelData[i],pixelData[i+1]);
gray=Math.Min(gray,pixelData[i+2]);
pixelData[i]=gray;
pixelData[i+1]=gray;
pixelData[i+2] =gray;
- Washed out color
double gray=(pixelData[i]*0.11)+(pixelData[i+1]*0.59)+(pixelData[i+2]*0.3);
double desaturation=0.75;
pixelData[i]=(byte)(pixelData[i]+desaturation*(gray-pixelData[i]));
pixelData[i+1]=(byte)(pixelData[i+1]+desaturation*(gray-pixelData[i+1]));
pixelData[i+2]=(byte)(pixelData[i+2]+desatuation*(gray-pixelData[i+2]));
- High saturation
If (pixelData[i]<0x33||pixelData[i]>0xE5)
{
pixelData[i]=0x00;
} else
{
pixelData[i]=0Xff;
}
If (pixelData[i+1]<0x33||pixelData[i+1]>0xE5)
{
pixelData[i+1]=0x00;
} else
{
pixelData[i+1]=0Xff;
}
If (pixelData[i+2]<0x33||pixelData[i+2]>0xE5)
{
pixelData[i+2]=0x00;
} else
{
pixelData[i+1]=0Xff;
}
一下是上面操作后的图像:
3. 截图
有时候,可能需要从彩色摄像头中截取一幅图像,例如可能要从摄像头中获取图像来设置人物头像。为了实现这一功能,首先需要在界面上设置一个按钮,代码如下:
<Window x:Class="KinectApplicationFoundation.MainWindow" xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation" xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" Title="ColorImageStreamFromKinect" Height="350" Width="525"> <Grid> <Image x:Name="ColorImageElement"></Image> <StackPanel HorizontalAlignment="Left" VerticalAlignment="Top"> <Button Content="Take Picture" Click="TakePictureButton_Click" /> </StackPanel> </Grid> </Window>
private void TakePictureButton_Click(object sender, RoutedEventArgs e) { String fileName = "snapshot.jpg"; if (File.Exists(fileName)) { File.Delete(fileName); } using (FileStream savedSnapshot=new FileStream(fileName,FileMode.CreateNew)) { BitmapSource image =(BitmapSource) ColorImageElement.Source; JpegBitmapEncoder jpgEncoder = new JpegBitmapEncoder(); jpgEncoder.QualityLevel = 70; jpgEncoder.Frames.Add(BitmapFrame.Create(image)); jpgEncoder.Save(savedSnapshot); savedSnapshot.Flush(); savedSnapshot.Close(); savedSnapshot.Dispose(); } }
为了演示,上面的代码中在当前目录创建了一个文件名。这是一种简单保存文件的方法。我们使用FileStream打开一个文件。JpegBitmapEncoder对象将UI上的图像转换为一个标准的JPEG文件,保存完后,需要调用对象的flush方法,然后关闭,最后释放对象。虽然这三部不需要,因为我们使用了using语句,这里是为了演示,所以把这三步加上了。
4. ColorImageStream对象图
到此为止,我们讨论了如何发现以及初始化Kinect传感器,从Kinect的影像摄像头获取图片。现在让我们来看看一些关键的类,以及他们之间的关系。下图展现了ColorImageStream的对象模型图。
ColorImageStream是KinectSensor对象的一个属性,如同KinectSensorde其它流一样,色彩数据流在使用之前需要调用Enable方法。ColorImageStream有一个重载的Enabled方法,默认的Eanbled方法没有参数,重载的方法有一个ColorImageFormat参数,他是一个枚举类型,可以使用这个参数指定图像格式。下表列出了枚举成员。默认的Enabled将ColorImageStream设置为每秒30帧的640*480的RGB影像数据。一旦调用Enabled方法后,就可以通过对象的Foramt属性获取到图像的格式了。
ColorImageStream 有5个属性可以设置摄像头的视场。这些属性都以Nominal开头,当Stream被设置好后,这些值对应的分辨率就设置好了。一些应用程序可能需要基于摄像头的光学属性比如视场角和焦距的长度来进行计算。ColorImageStream建议程序员使用这些属性,以使得程序能够面对将来分辨率的变化。
ImageStream是ColorImageStream的基类。因此ColorImageStream集成了4个描述每一帧每一个像素数据的属性。在之前的代码中,我们使用这些属性创建了一个WriteableBitmap对象。这些属性与ColorImageFormat的设置有关。ImageStream中除了这些属性外还有一个IsEnabled属性和Disable方法。IsEnabled属性是一个只读的。当Stream打开时返回true,当调用了Disabled方法后就返回false了。Disable方法关闭Stream流,之后数据帧的产生就会停止,ColorFrameReady事件的触发也会停止。当ColorImageStream设置为可用状态后,就能产生ColorImageFrame对象。ColorImageFrame对象很简单。他有一个Format方法,他是父类的ColorImageFormat值。他只有一个CopyPixelDataTo方法,能够将图像的像素数据拷贝到指定的byte数组中,只读的PixelDataLength属性定义了数组的大小PixelDataLength属性通过对象的宽度,高度以及每像素多少位属性来获得的。这些属性都继承自ImageFrame抽象类。
数据流的格式决定了像素的格式,如果数据流是以ColorImageFormat.RgbResolution640*480Fps30格式初始化的,那么像素的格式就是Bgr32,它表示每一个像素占32位(4个字节),第一个字节表示蓝色通道值,第二个表示绿色,第三个表示红色。第四个待用。当像素的格式是Bgra32时,第四个字节表示像素的alpha或者透明度值。如果一个图像的大小是640*480,那么对于的字节数组有122880个字节(width*height*BytesPerPixel=640*480*4).在处理影像时有时候也会用到Stride这一术语,他表示影像中一行的像素所占的字节数,可以通过图像的宽度乘以每一个像素所占字节数得到。
除了描述像素数据的属性外,ColorImageFrame对象还有一些列描述本身的属性。Stream会为每一帧编一个号,这个号会随着时间顺序增长。应用程序不要假的每一帧的编号都比前一帧恰好大1,因为可能出现跳帧现象。另外一个描述帧的属性是Timestamp。他存储自KinectSensor开机(调用Start方法)以来经过的毫秒数。当每一次KinectSensor开始时都会复位为0。
5. 获取数据的方式:事件模式 VS “拉”模式
目前为止我们都是使用KinectSensor对象的事件来获取数据的。事件在WPF中应用很广泛,在数据或者状态发生变化时,事件机制能够通知应用程序。对于大多数基于Kinect开发的应用程序来说基于事件的数据获取方式已经足够;但它不是唯一的能从数据流中获取数据的模式。应用程序能够手动的从Kinect数据流中获取到新的帧数据。
“拉”数据的方式就是应用程序会在某一时间询问数据源是否有新数据,如果有,就加载。每一个Kinect数据流都有一个称之为OpenNextFrame的方法。当调用OpenNextFrame的方式时,应用程序可以给定一个超时的值,这个值就是应用程序愿意等待新数据返回的最长时间,以毫秒记。方法试图在超时之前获取到新的数据帧。如果超时,方法将会返回一个null值。
当使用事件模型时,应用程序注册数据流的frame-ready事件,为其指定方法。每当事件触发时,注册方法将会调用事件的属性来获取数据帧。例如,在使用彩色数据流时,方法调用ColorImageFrameReadyEventArgs对象的OpenColorImageFrame方法来获取ColorImageFrame对象。程序应该测试获取的ColorImageFrame对象是否为空,因为有可能在某些情况下,虽然事件触发了,但是没有产生数据帧。除此之外,事件模型不需要其他的检查和异常处理。相比而言,OpenNextFrame方法在KinectSensor没有运行、Stream没有初始化或者在使用事件获取帧数据的时候都有可能会产生InvalidOperationException异常。应用程序可以自由选择何种数据获取模式,比如使用事件方式获取ColorImageStream产生的数据,同时采用“拉”的方式从SkeletonStream流获取数据。但是不能对同一数据流使用这两种模式。AllFrameReady事件包括了所有的数据流—意味着如果应用程序注册了AllFrameReady事件。任何试图以拉的方式获取流中的数据都会产生InvalidOperationException异常。
在展示如何以拉的模式从数据流中获取数据之前,理解使用模式获取数据的场景很有必要。使用“拉”数据的方式获取数据的最主要原因是性能,只在需要的时候采取获取数据。他的缺点是,实现起来比事件模式复杂。除了性能,应用程序的类型有时候也必须选择“拉”数据的这种模式。SDK也能用于XNA,他不同与WPF,它不是事件驱动的。当需要使用XNA开发游戏时,必须使用拉模式来获取数据。使用SDK也能创建没有用户界面的控制台应用程序。设想开发一个使用Kinect作为眼睛的机器人应用程序,他通过源源不断的主动从数据流中读取数据然后输入到机器人中进行处理,在这个时候,拉模型是比较好的获取数据的方式。下面的代码展示了如何使用拉模式获取数据:
private KinectSensor _Kinect; private WriteableBitmap _ColorImageBitmap; private Int32Rect _ColorImageBitmapRect; private int _ColorImageStride; private byte[] _ColorImagePixelData; public MainWindow() { InitializeComponent(); CompositionTarget.Rendering += CompositionTarget_Rendering; } private void CompositionTarget_Rendering(object sender, EventArgs e) { DiscoverKinectSensor(); PollColorImageStream(); }
代码声明部分和之前的一样。基于“拉”方式获取数据也需要发现和初始化KinectSensor对象。方法使用WriteBitmap来创建帧影像。最大的不同是,在构造函数中我们将Rendering事件绑定到CompositionTarget对象上。ComposationTarget对象表示应用程序中可绘制的界面。Rendering事件会在每一个渲染周期上触发。我们需要使用循环来取新的数据帧。有两种方式来创建循环。一种是使用线程,将在下一节中介绍。另一种方式是使用普通的循环语句。使用CompositionTarget对象有一个缺点,就是Rendering事件中如果处理时间过长会导致UI线程问题。因为时间处理在主UI线程中。所以不应在事件中做一些比较耗时的操作。Redering 事件中的代码需要做四件事情。必须发现一个连接的KinectSnesor,初始化传感器。响应传感器状态的变化,以及拉取新的数据并对数据进行处理。我们将这四个任务分为两个方法。下面的代码列出了方法的实现。和之前的代码差别不大:
private void DiscoverKinectSensor() { if(this._Kinect != null && this._Kinect.Status != KinectStatus.Connected) { this._Kinect = null; } if(this._Kinect == null) { this._Kinect = KinectSensor.KinectSensors.FirstOrDefault(x => x.Status == KinectStatus.Connected); if(this._Kinect != null) { this._Kinect.ColorStream.Enable(); this._Kinect.Start(); ColorImageStream colorStream = this._Kinect.ColorStream; this._ColorImageBitmap = new WriteableBitmap(colorStream.FrameWidth, colorStream.FrameHeight, 96, 96, PixelFormats.Bgr32, null); this._ColorImageBitmapRect = new Int32Rect(0, 0, colorStream.FrameWidth, colorStream.FrameHeight); this._ColorImageStride = colorStream.FrameWidth * colorStream.FrameBytesPerPixel; this.ColorImageElement.Source = this._ColorImageBitmap; this._ColorImagePixelData = new byte[colorStream.FramePixelDataLength]; } } }
下面的代码列出了PollColorImageStream方法的实现。代码首先判断是否有KinectSensor可用.然后调用OpneNextFrame方法获取新的彩色影像数据帧。代码获取新的数据后,然后更新WriteBitmap对象。这些操作包在using语句中,因为调用OpenNextFrame对象可能会抛出异常。在调用OpenNextFrame方法时,将超时时间设置为了100毫秒。合适的超时时间设置能够使得程序在即使有一两帧数据跳过时仍能够保持流畅。我们要尽可能的让程序每秒产生30帧左右的数据。
private void PollColorImageStream() { if(this._Kinect == null) { //TODO: Display a message to plug-in a Kinect. } else { try { using(ColorImageFrame frame = this._Kinect.ColorStream.OpenNextFrame(100)) { if(frame != null) { frame.CopyPixelDataTo(this._ColorImagePixelData); this._ColorImageBitmap.WritePixels(this._ColorImageBitmapRect, this._ColorImagePixelData, this._ColorImageStride, 0); } } } catch(Exception ex) { //TODO: Report an error message } } }
总体而言,采用拉模式获取数据的性能应该好于事件模式。上面的例子展示了使用拉方式获取数据,但是它有另一个问题。使用CompositionTarget对象,应用程序运行在WPF的UI线程中。任何长时间的数据处理或者在获取数据时超时 时间的设置不当都会使得程序变慢甚至无法响应用户的行为,因为这些操作都执行在UI线程上。解决方法是创建一个新的线程,然后在这个线程上执行数据获取和处理操作。 在.net中使用BackgroundWorker类能够简单的解决这个问题。代码如下:
private void Worker_DoWork(object sender, DoWorkEventArgs e) { BackgroundWorker worker = sender as BackgroundWorker; if(worker != null) { while(!worker.CancellationPending) { DiscoverKinectSensor(); PollColorImageStream(); } } }
首先,在变量声明中加入了一个BackgroundWorker变量 _Worker。在构造函数中,实例化了一个BackgroundWorker类,并注册了DoWork事件,启动了新的线程。当线程开始时就会触发DoWork事件。事件不断循环知道被取消。在循环体中,会调用DiscoverKinectSensor和PollColorImageStream方法。如果直接使用之前例子中的这两个方法,你会发现会出现InvalidOperationException异常,错误提示为“The calling thread cannot access this object because a different thread owns it”。这是由于,拉数据在background线程中,但是更新UI元素却在另外一个线程中。在background线程中更新UI界面,需要使用Dispatch对象。WPF中每一个UI元素都有一个Dispathch对象。下面是两个方法的更新版本:
private void DiscoverKinectSensor() { if(this._Kinect != null && this._Kinect.Status != KinectStatus.Connected) { this._Kinect = null; } if(this._Kinect == null) { this._Kinect = KinectSensor.KinectSensors .FirstOrDefault(x => x.Status == KinectStatus.Connected); if(this._Kinect != null) { this._Kinect.ColorStream.Enable(); this._Kinect.Start(); ColorImageStream colorStream = this._Kinect.ColorStream; this.ColorImageElement.Dispatcher.BeginInvoke(new Action(() => { this._ColorImageBitmap = new WriteableBitmap(colorStream.FrameWidth, colorStream.FrameHeight, 96, 96, PixelFormats.Bgr32, null); this._ColorImageBitmapRect = new Int32Rect(0, 0, colorStream.FrameWidth, colorStream.FrameHeight); this._ColorImageStride = colorStream.FrameWidth * colorStream.FrameBytesPerPixel; this._ColorImagePixelData = new byte[colorStream.FramePixelDataLength]; this.ColorImageElement.Source = this._ColorImageBitmap; })); } } }
private void PollColorImageStream() { if(this._Kinect == null) { //TODO: Notify that there are no available sensors. } else { try { using(ColorImageFrame frame = this._Kinect.ColorStream.OpenNextFrame(100)) { if(frame != null) { frame.CopyPixelDataTo(this._ColorImagePixelData); this.ColorImageElement.Dispatcher.BeginInvoke(new Action(() => { this._ColorImageBitmap.WritePixels(this._ColorImageBitmapRect, this._ColorImagePixelData, this._ColorImageStride, 0); })); } } } catch(Exception ex) { //TODO: Report an error message } } }
到此为止,我们展示了两种采用“拉”方式获取数据的例子,这两个例子都不够健壮。比如说还需要对资源进行清理,比如他们都没有释放KinectSensor对象,在构建基于Kinect的实际项目中这些都是需要处理的问题。
“拉”模式获取数据跟事件模式相比有很多独特的好处,但它增加了代码量和程序的复杂度。在大多数情况下,事件模式获取数据的方法已经足够,我们应该使用该模式而不是“拉”模式。唯一不能使用事件模型获取数据的情况是在编写非WPF平台的应用程序的时候。比如,当编写XNA或者其他的采用拉模式架构的应用程序。建议在编写基于WPF平台的Kinect应用程序时采用事件模式来获取数据。只有在极端注重性能的情况下才考虑使用“拉”的方式。
6. 结语
本节介绍了采用WriteableBitmap改进程序的性能,并讨论了ColorImageStream中几个重要对象的对象模型图并讨论了个对象之间的相关关系。最后讨论了在开发基于Kinect应用程序时,获取KinectSensor数据的两种模式,并讨论了各自的优缺点和应用场合,这些对于之后的DepthImageSteam和SkeletonStream也是适用的。
下一篇文章将会对KinectSensor特有的红外传感器产生的DepthImageStream进行介绍,敬请期待。
出处:http://www.cnblogs.com/yangecnu/
本作品由yangecnu 创作,采用知识共享署名-非商业性使用-禁止演绎 2.5 中国大陆许可协议进行许可。 欢迎转载,但任何转载必须保留完整文章,在显要地方显示署名以及原文链接。如您有任何疑问或者授权方面的协商,请 给我留言。