蒼時弦也
蒼時弦也
資深軟體工程師
發表於

Zigfu 跨平台的 Kinect SDK

之前和系上老師借了一個多學期的 Kinect 卻只有做完用 Mac 連接 Kinect 並且搭配 Unity3D 的功課,就一直沒有成果。 暑假也即將結束,緊接而來的就是全力投入在畢業製作,不過在此之前,還是得先把答應老師的功課做完。

雖然時間不足以製作一款遊戲,但是將 Zigfu 這款非常好用的工具使用介紹完整的說明,我想多少也算是能夠完成一部份的任務了!


Zigfu 基本上是設計給 Web 使用的,因此目前支援是 JavaScript 和 Unity3D 兩款(Flash 過了半年依舊開發中⋯⋯) 不過 Zigfu 卻替 Mac 使用者解決了一個問題,就是 OpenNI / OpenNI2 的安裝,沒有驅動就無法使用 Kinect 是 Mac 用戶的痛。

不過很可惜的是,目前最新的 Mac 驅動只能順利與 Kinect 溝通一分鐘左右,之後就是當機。 也因此,這系列的文章都是針對 Windows 所說明的,但是成品對 Mac 的支援是確定的,即使會當掉⋯⋯

至於 Zigfu 大致上做了什麼呢? 將驅動程式包裝起來,協助使用者安裝(Windows 使用者需要自己安裝驅動)並且提供 ZDK (SDK) 讓開發者可以用統一的界面,存取 Kinect(官方)、OpenNI、OpenNI2 的 Middleware。

關於 OpenNI / OpenNI 2 的介紹,可以參考這篇文章

這篇文章會用 Unity3D 來解釋一些關於 Zigfu 的 ZDK 基本使用。 最基本的就是我們需要能透過 Zigfu 讀取到影像、深度、骨架等資料,才能夠繼續後續的開發與使用。

安裝

首先,我們到官方網站的 Plugin 下載頁面 去下載 Plugin。 (Windows 用戶應該是不需要,至於使用的 Kinect 是 For Windows 還是 For Xbox 要注意驅動是否正確。)

完成之後,再到 Unity3D ZDK 下載頁面下載適合 Unity3D 的 ZDK (是一個 Unitypackage 檔案,並且有含範例。)

之後在 Unity3D 開啟新專案,匯入 Custom Package 之後,就可以使用了。

注意:因為 ZDK 是用 DLL 包裝的,所以你必須使用 Unity3D Pro 才能夠正常使用

了解 Zig 元件

如果有點開範例檔案,會發現每一個範例檔案都有一個叫做 Zigfu 的 GameObject 在場景上,而這個 Zigfu 物件,都附加了一個叫做 Zig 的 Script 在上面。

假設停用了 Zigfu 物件,那麼所有相關 Kinect 的功能都會失效,並且出現 Failed load driver and middleware... 這樣的錯誤。

那麼 Zig 這個 Script 做了些什麼呢?

 1// 略
 2    public ZigInputType inputType = ZigInputType.Auto;
 3    //public bool UpdateDepthmap = true;
 4    //public bool UpdateImagemap = false;
 5    //public bool UpdateLabelmap = false;
 6    //public bool AlignDepthToRGB = false;
 7    public ZigInputSettings settings = new ZigInputSettings();
 8    public List<GameObject> listeners = new List<GameObject>();
 9    public bool Verbose = true;
10    
11    void Awake () {
12        #if UNITY_WEBPLAYER
13        #if UNITY_EDITOR
14        Debug.LogError("Depth camera input will not work in editor when target platform is Webplayer. Please change target platform to PC/Mac standalone.");
15        return;
16        #endif
17        #endif
18
19        ZigInput.InputType = inputType;
20        ZigInput.Settings = settings;
21        //ZigInput.UpdateDepth = UpdateDepthmap;
22        //ZigInput.UpdateImage = UpdateImagemap;
23        //ZigInput.UpdateLabelMap = UpdateLabelmap;
24        //ZigInput.AlignDepthToRGB = AlignDepthToRGB;
25        ZigInput.Instance.AddListener(gameObject);
26	}
27// 略

上面是節錄自 Zig.cs 這個檔案的內容,我們可以發現裡面對 ZigInput 設定了 InputTypeSettings 兩個數值。

在 Unity3D 裡面看到就會是像這樣: 螢幕快照 2014-09-13 下午3.01.19.png

簡單來說 Zig 元件幫我們把「讀取方式」以及讀取的方式設定好了!

在 InputType 裡面可以選擇 Auto / KinectSDK / OpenNI / OpenNI2 幾個選項,在預設的 Auto 狀況下,Zigfu 會自動依照 KinectSDK > OpenNI2 > OpenNI 的順序嘗試呼叫,當成功時就使用該驅動作為讀取 Kinect 資料的驅動。 Settings 裡面則會看到一些關於讀取資料的設定,像是是否要更新 Depth (深度資訊) 等等。

最後,我們需要注意 Awake 方法的最後一行 ZigInput.Instance.AddListener(gameObject) 這一句程式碼。

在程式開發慣例中 Instance 通常是指物件的實體(就 Zigfu 的設計上,應該是屬於單例的設計,簡單說就是只會存在一個。)

gameObject 在 Unity3D 通常是指自己本身,而 AddListener 在這邊指的是「當更新時也一併更新這個物件」的意思。

Listener 基本上設計類似于觀察者這種慣例,在 Unity3D 就類似於 Update 的感覺,在 Zigfu 中選擇了自行實作,跟 Unity3D 分開處理。 某方面也算是比較恰當的做法,畢竟 Kinect 裡面有自己的硬體,跟 Unity3D 分離就可以不受玩家主機的硬體限制。

從 Kinect 讀取影像

首先我們要在深入了解 ZigInput 的作用,我們可以從範例的 ZigImageView.cs 這個檔案了解到一些蛛絲馬跡。

1// 略
2    void Zig_Update(ZigInput input)
3    {
4        UpdateTexture(ZigInput.Image);
5    }
6// 略

前面提到的 AddListener 動作中,每當 Kinect 更新畫面並且被 Zigfu 接收時,會做類似 Unity3dD 的 Update 動作,也就是上面這段程式碼所寫的 Zig_Update 方法。

從這段程式碼可以看到,如果我們需要讀取影像,可以從 ZigInput 拿到一個 Image 資料來使用。

除了 Image 之外,我們還能拿到 Depth (深度) 以及 Label Map (標記) 不過 Label Map 在範例中是黑色的畫面,似乎也沒有人了解用途,因此就不多做討論。

接下來,我們先產生新的場景(Scene / Ctrl + N)並且新增一個 Empty GameObject 用來放置 Zig 元件。

螢幕快照 2014-09-13 下午3.22.26.png

產生好物件之後,就馬上把物件命名為 Zigfu 這會是一個好習慣,在中後期專案變大的時候,檔案跟物件沒有好好命名的話,就會碰到非常多問題。而團隊合作的時候更是明顯,因此別忘記修改物件名稱。

螢幕快照 2014-09-13 下午3.22.43.png

在 Zigfu 的 ZDK 匯入到 Unity3D 後,也已經自動對選單增加好所有可用的元件。 我們在 Script 類型的選件中選擇 Zig 就可以對 Zigfu 物件新增這個元件了!

螢幕快照 2014-09-13 下午3.26.45.png

預設的 Zig 元件沒有開啟 Update Image 的選項,因此我們要自己勾選起來。 (上圖還是未勾選狀態)

螢幕快照 2014-09-13 下午3.30.52.png

接著,我們會需要一個 Plane(平面)用來顯示 Kinect 讀取到的影像。

螢幕快照 2014-09-13 下午3.32.26.png

接著調整 Plane(這邊我已經重新命名為 ImageViewer) 跟 Main Camera 讓平面可以順利被攝影機完整照到。

螢幕快照 2014-09-13 下午3.33.51.png

在開始之前,我們先用範例的 ImageViewer 元件來測試效果。 現在啟動遊戲的話,應該可以順利看到 Kinect 的 Camera 照到的影像被更新到 Plane 上。

不過應該是上下顛倒的,不論是 WebCam 或者 Kinect 被照進去的狀況下都是這樣,旋轉一下就可以了! 影像有點暗是因為 3D 物件上面沒有打光,只要在場景上新增光源即可。

自定圖片讀取

我們先將 ZigImageViewer.cs 的內容複製到一個新的檔案 CustomImageViewer.cs 並且以此為基礎修改出我們自己的「圖片讀取功能」

螢幕快照 2014-09-13 下午4.11.40.png

並且把原本的 ImageViewer Panel 的 Script 改為 CustomImageViewer 來套用我們自己的讀取處理。 (這邊最好先執行看看,是否可以順利運作。要注意 class ZigImageViewer 得改為跟檔名一樣的 class CustomImageViewer 才會正常運作。)

修改之前,第一步是要了解範例的 ImageViewer 在做什麼。 下面會直接將解釋標記在程式碼中。

 1public class CustomImageViewer : MonoBehaviour {
 2	// 指定繪製的目標(這邊直接畫在自身,所以不需要)
 3	public Renderer target;
 4  // 解析度設定,最高支援到 640x480 數值越低越順暢
 5	public ZigResolution TextureSize = ZigResolution.QQVGA_160x120;
 6  // 材質貼圖(用來存 Kinect 讀進來的影像)
 7	Texture2D texture;
 8  // 解析度資料
 9	ResolutionData textureSize;
10	
11	Color32[] outputPixels; // 將影像轉換為像素陣列
12  
13	// 讀取器的初始化
14	void Start()
15	{
16		if (target == null) { // 檢查是否有指定目標
17			target = renderer; // 沒有的話就設定為自己
18		}
19    // 將讀取的解析度轉換為解析度資料(後面會用來畫在材質上)
20		textureSize = ResolutionData.FromZigResolution(TextureSize);
21    // 產生新的 2D 材質(用剛剛轉換的解析度資料)
22		texture = new Texture2D(textureSize.Width, textureSize.Height);
23		// 設定材質的顯示方式( Clamp 是填滿,另一個 Repeat 則是重複貼滿 )
24    texture.wrapMode = TextureWrapMode.Clamp;
25    // 設定 Plane 的材質為剛剛新增的材質
26		renderer.material.mainTexture = texture;
27    // 產生一組可以儲存影像像素資料的陣列
28		outputPixels = new Color32[textureSize.Width * textureSize.Height];
29    // 告訴 Zigfu 當畫面更新時要呼叫這個原件做更新處理
30		ZigInput.Instance.AddListener(gameObject);
31	}
32	
33  // 更新材質
34  // 接收的是一個 ZigImage 資料
35	void UpdateTexture(ZigImage image)
36	{
37  	// 讀取原始的影像資料( Zigfu 會傳回像素陣列 )
38		Color32[] rawImageMap = image.data;
39    // 將陣列換算成 2D 圖像的前置準備
40    // 後面會詳細解釋這個部分
41		int srcIndex = 0;
42		int factorX = image.xres / textureSize.Width;
43		int factorY = ((image.yres / textureSize.Height) - 1) * image.xres;
44   	
45		// 反轉 Y 軸(因為讀取到的影響一開始是左右相反的,需要再轉回來一次)
46		for (int y = textureSize.Height - 1; y >= 0; --y, srcIndex += factorY) {
47			int outputIndex = y * textureSize.Width;// 輸出影像的陣列位置
48			for (int x = 0; x < textureSize.Width; ++x, srcIndex += factorX, ++outputIndex) {
49				outputPixels[outputIndex] = rawImageMap[srcIndex]; // 將像素資料複製到輸出影像
50			}
51		}
52		texture.SetPixels32(outputPixels); // 更新材質的像素資料
53		texture.Apply(); // 套用像素資料(材質內容被更新)
54	}
55	
56	void Zig_Update(ZigInput input)
57	{
58		UpdateTexture(ZigInput.Image);
59	}
60}

這邊會解釋兩個東西,一個是 Renderer (渲染器) 另一個是陣列轉為 2D 坐標的方法。

Renderer 基本上會附加在每一個 Unity3D 上「可以被看到」的物件,他用來處理材質球跟材質如何繪製到模型上。 也因此,一旦 Renderer 被關掉,就無法看到物件,這邊用程式的方式設定材質球。

至於陣列轉換為 2D 坐標的方法,其實就是非常簡單的數學邏輯。

假設有一個 10px 乘以 10px 的影像,那麼他就會有 10 * 10 = 100 個像素。 那麼第 11 個像素的坐標在哪裡呢?可以用下面的方式推算出來。

位置 = (y * 寬) + x

所以說 11 要先除以 10 會得到餘數 1 接著用 11 剪掉 1 就得到一個可以被「寬」整除的值,都計算完畢後,就可以知道第 11 個像素位置在 x = 0, y = 1 的位置(註:陣列中是從 0 ~ 99 所以算完會變成 0,1 的坐標)

多想幾次就會理解其中的原理了!

接下來,我們對 UpdateTexture 方法做一些小修改,讓畫面變成黑白的灰階畫面。

 1// 略
 2  void UpdateTexture(ZigImage image)
 3	{
 4		Color32[] rawImageMap = image.data;
 5		int srcIndex = 0;
 6		int factorX = image.xres / textureSize.Width;
 7		int factorY = ((image.yres / textureSize.Height) - 1) * image.xres;
 8
 9		Color buffer;
10    byte grayscaleByte;
11		// invert Y axis while doing the update
12		for (int y = textureSize.Height - 1; y >= 0; --y, srcIndex += factorY) {
13			int outputIndex = y * textureSize.Width;
14			for (int x = 0; x < textureSize.Width; ++x, srcIndex += factorX, ++outputIndex) {
15				buffer = new Color(rawImageMap[srcIndex].r, rawImageMap[srcIndex].g, rawImageMap[srcIndex].b, rawImageMap[srcIndex].a);
16        grayscaleByte = (byte)buffer.grayscale;
17				outputPixels[outputIndex] = new Color32(grayscaleByte, grayscaleByte, grayscaleByte, (byte)rawImageMap[srcIndex].a);
18			}
19		}
20		texture.SetPixels32(outputPixels);
21		texture.Apply();
22	}
23// 略

首先,先增加 Color bufferbyte grayscaleByte 方便處理。

grayscale 只在 Color 下可以使用,而 Color32 則沒有這個功能,因此需要先手動將 Color32 轉為 Color

接著 buffer = new Color(rawImageMap[srcIndex].r, rawImageMap[srcIndex].g, rawImageMap[srcIndex].b, rawImageMap[srcIndex].a); 基於拿到的顏色產生一個新的 Color。 因為 Color32 需要用 byte 指定顏色,因此我們用 grayscaleByte = (byte)buffer.grayscale; 將灰階化的數值轉為 byte 方便使用。

最後調整原本複製像素的方式,改為 outputPixels[outputIndex] = new Color32(grayscaleByte, grayscaleByte, grayscaleByte, (byte)rawImageMap[srcIndex].a); 將一個灰階版本的像素複製進去。

現在,執行遊戲的話就可以看到灰階的畫面。

這篇文章就到此告一段落,至於 Depth 跟 Label Map 的使用方式,基本上是一樣的。目前學習的東西用一般的 WebCam 也能做到,下一篇文章會討論關於骨架的使用。