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進行介紹,敬請期待。
作者: yangecnu(yangecnu's Blog on 博客園)
出處:http://www.cnblogs.com/yangecnu/