1. 程式人生 > >Kinect for Windows SDK開發入門(三):基礎知識 下

Kinect for Windows SDK開發入門(三):基礎知識 下

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;

}

一下是上面操作後的影象:

Normalred

InvertedZombie

graygray2

washoutlog

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屬性獲取到影象的格式了。

image

image

    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進行介紹,敬請期待。