OpenNI 的手部追蹤、外加簡單的滑鼠模擬

前一陣子有用 Qt、加上之前介紹過的東西,寫了幾個有圖形介面的 OpenNI 程式了∼不過這篇回到純粹的 Console,來講一下 OpenNI 裡面一個重要功能:手部追蹤吧∼

實際上,如果只是要追蹤手部的位置,之前介紹過的人體骨架的追蹤,其實就可以做到了;不過雖然去追蹤整個人體骨架也可以單獨抓出手部的資料來用,但是由於人體骨架的分析與追蹤需要能抓到幾乎整個人的外型才能用,所以其實相對比較不方便。

而也因此,OpenNI 裡,另外還有一個 xn::HandsGenerator 的 node,是可以搭配手勢偵測、專門用來追蹤手部的位置;透過 xn::HandsGenerator 的話,就可以不需要抓到整個人體,只針對手部進行追蹤了∼

在手部追蹤的部分,OpenNI 官方的 User Cuide 裡面,有一段「Working with Hand Point」,已經有提供了基本的使用範例,基本上 Heresy 這邊的程式,就是針對這段程式做修改而成的。

而在開始之前,先看一下標準的整個手部追蹤的流程。以官方的範例來看,要使用 Hand Generator 來進行手部的追蹤的話,是需要搭配手勢偵測的 Gesture Generator 來進行的;他的基本流程如下:

  1. 透過 Gesture Generator 來偵測特定的手勢。
  2. 在偵測到手勢後,呼叫 xn::HandsGenerator::StartTracking()、開始進行追蹤。
    同時,也關閉手勢偵測的功能,並免重覆啟動。
  3. 當開始追蹤後,會執行 hands generator 的 HandCreate callback function 一次,之後每當有變化的時候,都會執行 HandUpdate 這個 callback function;如果手部超出可偵測範圍的話,則會呼叫 HandDestroy 這個 callback function、並停止追蹤。
  4. 為了讓手部追蹤可以重新開始,重新設定 Gesture Generator 開始偵測手勢。

而由於 Heresy 想試著用手部追蹤的功能來實作 Windows 下的滑鼠模擬器,所以在這邊則是做了一些修改,整個程式的流程變成:

  1. 透過 Gesture Generator 來偵測 sGestureToUse 這個手勢(這邊是使用「RaiseHand」)。
  2. 在偵測到 sGestureToUse 這個手勢後,開始進行追蹤手部位置;同時,也停止對於 sGestureToUse 這個手勢的偵測。
  3. hands generator 當開始追蹤手部位置
    • 當開始追蹤、HandCreate() 被呼叫時,開始偵測 sGestureToPress 這個手勢(這邊是使用「Click」),當作滑鼠左鍵的觸發條件。
    • 當手部位置變化時,HandUpdate() 會被呼叫、並在此時移動系統的滑鼠位置。
  4. 如果手超出可偵測範圍的話,則會呼叫 HandDestroy()、並停止追蹤。
    此時,停止對於 sGestureToPress  的偵測(左鍵)、並重新開始偵測 sGestureToUse 這個手勢。

而整個程式碼,就是下面這樣子:

#include <stdlib.h>
#include <string.h>
#include <iostream>

#include <XnCppWrapper.h>

using namespace std;

struct SNode
{
char* sGestureToUse;
char* sGestureToPress;
xn::DepthGenerator mDepth;
xn::HandsGenerator mHand;
xn::GestureGenerator mGesture;
//CVMouse    mVMouse;
};


// callback function: Gesture Recognized
void XN_CALLBACK_TYPE GestureRecognized( xn::GestureGenerator& generator,
const XnChar* strGesture,
const XnPoint3D* pIDPosition,
const XnPoint3D* pEndPosition,
void* pCookie )
{
SNode* pNodes = ((SNode*)pCookie);
if( strcmp( strGesture, pNodes->sGestureToPress ) == 0 )
{
cout << "Left Button" << endl;
//pNodes->mVMouse.LeftButtonClick();
}
else if( strcmp( strGesture, pNodes->sGestureToUse ) == 0 )
{
cout << "Start moving" << endl;
generator.RemoveGesture(strGesture);
pNodes->mHand.StartTracking( *pEndPosition );
}
}


// callback function: start to track hand
void XN_CALLBACK_TYPE HandCreate( xn::HandsGenerator& generator,
XnUserID nId,
const XnPoint3D* pPosition,
XnFloat fTime,
void* pCookie )
{
SNode* pNodes = ((SNode*)pCookie);
cout << "New Hand: " << nId << " detected! ";
cout << pPosition->X << "/" << pPosition->Y << "/" << pPosition->Z << endl;
pNodes->mGesture.AddGesture( pNodes->sGestureToPress, NULL );
}


// callback function: hand position changed
void XN_CALLBACK_TYPE HandUpdate( xn::HandsGenerator& generator
XnUserID nId,
const XnPoint3D* pPosition,
XnFloat fTime,
void* pCookie )
{
SNode* pNodes = ((SNode*)pCookie);
XnPoint3D wPos;
pNodes->mDepth.ConvertRealWorldToProjective( 1, pPosition, &wPos );

cout << wPos.X << "/" << wPos.Y << endl;
//pNodes->mVMouse.MoveMouse( wPos.X, wPos.Y );
}


// callback function: the hand disappear
void XN_CALLBACK_TYPE HandDestroy( xn::HandsGenerator& generator,
XnUserID nId,
XnFloat fTime,
void* pCookie )
{
SNode* pNodes = ((SNode*)pCookie);
cout << "Lost Hand: " << nId << endl;
pNodes->mGesture.AddGesture( pNodes->sGestureToUse, NULL );
pNodes->mGesture.RemoveGesture( pNodes->sGestureToPress );
}


// main function
int main( int argc, char** argv )
{
// 1. initial context
xn::Context mContext;
mContext.Init();

// 2a. create OpenNI nodes
SNode mNodes;
mNodes.mDepth.Create( mContext );
mNodes.mGesture.Create( mContext );
mNodes.mHand.Create( mContext );

// 2b set some variable
mNodes.mHand.SetSmoothing( 0.5f );
mNodes.sGestureToPress = "Click";
mNodes.sGestureToUse = "RaiseHand";

// 3. set callback of gesture generator
XnCallbackHandle hGesture;
mNodes.mGesture.RegisterGestureCallbacks( GestureRecognized, NULL,
                                            &mNodes, hGesture );
mNodes.mGesture.AddGesture( mNodes.sGestureToUse, NULL );

// 4 set callback of hand generator
XnCallbackHandle hHand;
mNodes.mHand.RegisterHandCallbacks( HandCreate, HandUpdate, HandDestroy,
&mNodes, hHand );

// 5. start generate data, and enter main loop to update data
mContext.StartGeneratingAll();
while( true )
mContext.WaitAndUpdateAll();

// 7. stop and shutdown
mContext.StopGeneratingAll();
mContext.Release();

return 0;
}

首先,這邊的 SNode 是 Heresy 把 OpenNI 的相關 node 都包起來的一個結構,主要是為了在 callback function 可以透過他的 cookie 來傳遞所有需要的資料。其中,有一個被註解掉的 CVMouse,這是 Heresy 自己寫來傳送 Windows 滑鼠事件的類別,最後會另外再提,在這邊會先把相關的程式註解掉。

在 callback function 的部分,這邊有定義了四個函式;GestureRecognized() 是給 gesture generator 用的,而 HandCreate()HandUpdate()HandDestroy() 則是給 hands generator 用的。其中,gesture generator 的 callback function 介面在之前《OpenNI 的手勢偵測》時就有講過了,所以這邊就不提了。

而 hands generator 的三個 callback function,在介面上大致上相同,除了 HandDestroy 因為是已經偵測不到手的狀態、所以沒有位置資訊外,其他的參數都相同。這些參數的簡單說明如下:

  • xn::HandsGenerator& generator:Hands Generator 本身的參考
  • XnUserID nId:用來識別不同手的 ID
  • const XnPoint3D* pPosition:目前的手部位置
  • XnFloat fTime:時間,以秒為單位
  • void* pCookie:使用者自己額外傳遞的資料

而要註冊這三個 callback function,則是呼叫 xn::HandsGeneratorRegisterHandCallbacks() 這個函式,並依序指派這幾個自己寫的函式,也就是程式中 main() 裡面,「// 4 set callback of hand generator」的部分 ;在這邊,Heresy 是把型別為 SNodemNode 當作額外的資料(cookie)傳遞進去,如此一來所有的 callback function 就都能使用 mNode 裡記錄的資料(主要是 OpenNI 的各個 node)了。

這幾個 callback function 裡面在做的事,則大致如下:

GestureRecognized()

當 Gesture Generator 偵測到手勢時會執行的函式。

在這個程式裡,Gesture Generator 同一個時間只會去偵測一個手勢:還沒有開始追蹤手部的時候,是去偵測 sGestureToUse(RaiseHand)這個手勢,當正在追蹤手部的時候則是 sGestureToPress(Click)這個手勢。

當被呼叫到的時候,如果偵測到的是 sGestureToPress 的話,就代表是要按下滑鼠左鍵,在這邊先簡單地輸出一個訊息;之後則是要真正送出 Windows Evenet,來模擬滑鼠左鍵按下的效果。

而如果是 sGestureToUse 的話,則是代表開始追蹤這個偵測到的手部的所在位置,所以除了停止偵測 sGestureToUse 外,也要執行 hands generator 的 StartTracking()、開始追蹤這個手部的位置;而要執行 StartTracking() 這邊是需要把手不目前的位置(pEndPosition)當作參數傳遞進去,讓 hands generator 知道追蹤的起始點。

HandCreate()

Hands Generator 開始追蹤手部位置時會被呼叫的函式。

這裡除了輸出一些訊息,讓使用者知道有抓到手部位置外,也透過呼叫 Gesture Generator 的 AddGesture() 函式,開始偵測 sGestureToPress(Click)這個手勢,當作滑鼠左鍵的觸發事件。

HandUpdate()

當 Hands Generator 正在追蹤的手部位置改變時、會被呼叫的函式。

由於 Windows 的滑鼠移動是 2D 的、而 Hands Generator 所追蹤的手部位置是 3D 的(真實世界座標系),所以這邊要先透過 Depth Generator 的 ConvertRealWorldToProjective() 函式,把目前手部的位置(pEndPosition)轉換成投影在螢幕平面上的位置(wPos)。

而目前在轉換後,是只有把他的座標做輸出,之後則是要再透過 CVMouse 送出 Windows 的滑鼠事件,來模擬滑鼠移動的效果。

HandDestroy()

當 Hands Generator 無法繼續追蹤手部位置時,會被呼叫的函式。

基本上,Hands Generator 會自動停止對於手部位置的追蹤,不需要做額外的處理。

不過為了停止滑鼠左鍵動作模擬,所以需要執行 Gesture Generator 的 RemoveGesture() 來停止對 sGestureToPress 這個手勢的偵測;同時為了要讓接下來還可以重新開始模擬滑鼠移動,所以也要透過 AddGesture() 來重新開始對 sGestureToUse 這個手勢的偵測。

而除了這四個 callback function 外,接下來就是主程式了∼這邊的主程式也相當簡單,基本上就是按照之前的做法,對 OpenNI 環境進行初始化;比較特別的,應該只有這次是把 OpenNI 的 node 都集中放在 SNode 這個型別的變數 mNode 裡而已。(// 2a. create OpenNI nodes

這邊有要用到的 Node,除了 Gesture Generator 和 Hands Generator 外,還有一個 Depth Generator,他是純粹為了做手部座標系統轉換而建立的,實際上沒有其他的用途;不過由於以預設的參數執行的話,手部抓到的 X 軸應該會是勁射過的結果,所以有需要的話,這邊可能會要加上對於 depth map 的鏡射的控制。

mNodes.mDepth.GetMirrorCap().SetMirror( true );

此外,在「// 2b set some variable」的部分,除了設定了 hands generator 的平滑化之外,也針對 SNode 內的 sGestureToPresssGestureToUse 這兩個手勢名稱做了設定;有需要的話,也可以自己換成別的手勢(不過由於 NITE 只有支援四個手勢,所以也沒什麼好選的)。

再接下來,就是對 Gesture generator 和 hands generator 做 callback function 的設定了∼而設定完成後,一樣是先呼叫 context 的 StartGeneratingAll(),開始整個資料流的處理,然後再透過無窮迴圈裡的 WaitAndUpdateAll() 來讓 OpenNI 更新資料、做進一步的處理了∼

而到此為止,一個簡單的手部追蹤的 OpenNI 的程式也就完成了∼只要執行起來後,在感應器前方舉起手來,他就會開始追蹤這隻手的位置,並把位置資訊輸出出來了∼而如果在追蹤的過程中做出 click(手往前推)的動作的話,也會在 console 裡輸出「Left Button」的字樣、代表要按下滑鼠左鍵。


不過由於這邊還沒有實作 CVMouse 這個送出 Windows 滑鼠事件的類別,所以實際上這些動作目前還不會有實際的效果。而接下來,Heresy 則大概提一下怎麼實作前面被註解掉的 CVMouse,然後進行滑鼠的模擬。

CVMouse 這個 class 的內容基本上如下:

class CVMouse
{
public:
unsigned int uX1;
unsigned int uX2;
unsigned int uY1;
unsigned int uY2;

CVMouse()
{
uX1 = 20;
uX2 = 620;
uY1 = 20;
uY2 = 460;
}

void MoveMouse( float X, float Y ) const
{
unsigned int wX = ( X - uX1 ) * 65535 / ( uX2 - uX1 ),
wY = ( Y - uY1 ) * 65535 / ( uY2 - uY1 );

mouse_event( MOUSEEVENTF_ABSOLUTE|MOUSEEVENTF_MOVE, wX, wY, NULL, NULL );
}

void LeftButtonClick() const
{
mouse_event( MOUSEEVENTF_LEFTDOWN|MOUSEEVENTF_LEFTUP, 0, 0, NULL, NULL );
}
};

在 Windows 環境下,要用 C 程式中送出滑鼠的事件,基本上是要呼叫 Windows 提供的 mouse_event() 這個函式;要使用它需要 include windows.h 裡這個 header 檔,並且在專案要設定連結 user32.lib 這個 lib 檔。而這個函式的詳細說明,可以參考 MSDN 上的介紹(網頁),Heresy 這邊只簡單地提一下有用到的部分。

首先,他第一個參數是定義好的滑鼠事件,像 Heresy 這邊用的是 MOUSEEVENTF_ABSOLUTEMOUSEEVENTF_MOVEMOUSEEVENTF_LEFTDOWNMOUSEEVENTF_LEFTUP 這四個;而這些事件都是可以同時觸發的,只要加上 bitwise 的 or(|)就可以了。

像在移動滑鼠用的 MoveMouse() 裡,Heresy 所送出的就是 MOUSEEVENTF_ABSOLUTE|MOUSEEVENTF_MOVE 這個事件,他是代表要把滑鼠游標移動到絕對座標上的某個位置;而如果沒有指定 MOUSEEVENTF_ABSOLUTE、只有 MOUSEEVENTF_MOVE 的話,則是代表是以相對於現在的游標位置來做移動。

而接下來的兩個參數,就是滑鼠的 (X, Y) 的座標了∼不過這邊 Windows 是要使用 normalize 過的值來當作座標的值:基本上它所需要的 X / Y 的值是無視螢幕的解析度設定、範圍是 0 – 65535 的值;以 X 軸來說,0 就是代表螢幕的最左邊、65535 則是螢幕的最右方。所以在這邊,拿到的 X / Y 座標還需要重新對應到 0 – 65535 之間才行。

Heresy 這邊則是透過記錄在 CVMouse 裡的 uX1uX2uY1uY2 這四個變數,來當作滑鼠手部位置的移動範圍;基本上,(uX1,uY1)是畫面的左上方、(uX2,uY2)則是畫面的右下方。Heresy 是假設畫面的大小是 640 x480,而上下左右各留 20 個像素的空間當作邊緣,所以就變成是程式碼內的數字了。之後,則是在透過程式內的計算公式換算,把傳入的 X / Y 座標,都對應到 0 – 65535 之間當作參數傳入就可以了∼

mouse_event() 最後的兩個參數在基本操作上比較用不到,所以在這邊就只傳 NULL 進去、也跳過不說明了。而在滑鼠事件的部分方面,當然也還有其他像是右鍵、中鍵、滾輪等的事件,不過在這邊也不多提了。

接下來在 MouseLeftClick() 的部分,則是同時送出 MOUSEEVENTF_LEFTDOWNMOUSEEVENTF_LEFTUP 兩個事件,來達到點集滑鼠左鍵的效果。

當把 CVMouse 這個 class 加到前面的程式碼裡,然後把之前相關的註解取消後,基本上就完成了一個極簡單的 OpenNI 滑鼠模擬器了∼只要把手抬起來,讓 OpenNI 開始追蹤手的位置後,滑鼠就會隨著手的移動而動;而只要把手往前推、觸發到 NITE 的 Click 手勢,就會等同按下滑鼠的左鍵了∼


這篇文章的程式到這邊,就算告一個段落了。而雖然這的確算是完成了一個極簡單的手部追蹤滑鼠模擬器(虛擬滑鼠),但是如果真的使用,可以發現它有幾個明顯的問題。

  1. 使用「Click」這個手勢來當作滑鼠左鍵的觸發動作,非常地不合適!

    由於 NITE 目前能支援的手勢非常地少,雖然「Click」(往前推)已經算是最適合的了,但是在使用上還是有相當大的問題…他的問體主要如下:

    1. 首先,手勢本身的觸發條件本身就算是在比較好的情況下,在 Heresy 來看本來就有點不容易達成;而當又把手的位置當作滑鼠游標後,手往往會在一些不容易做到「往前推」這個動作的角度,也因此就無法觸發滑鼠左鍵了…
    2. 其次,Click 這個要把手往前推的動作,基本上本身就是靠移動手部的位置來達成的,而也因此,連帶地一定會移動滑鼠游標的位置!所以當左鍵點下去的時候,滑鼠的游標其實大多已經不在要點的那個位置上了…再加上一般視窗環境的按鈕都不大,所以也就很難點到自己想要點的東西了。

    但是由於目前 NITE 又沒有支援其他不需要移動手部位置的手勢,所以如果要做到效果比較好的滑鼠左鍵觸發動作偵測,勢必就得自己去寫自己的條件判斷了…(話說,記得微軟 XBox 360 應該是判斷手的位置停在按鈕上一段時間,就當作按下去?)

  2. 移動的精確度不高

    這部分主要是由於現在電腦的桌面解析度大多已經大幅地提高,而相對於螢幕的解析度,其實 OpenNI 相容硬體所能提供的深度畫面解析度,其實算是嚴重不足的;像是 Kinect 一般使用的解析度也不過就 640 x 480,如果以 1920 x 1080 的螢幕解析度來說,在最好的狀況下,滑鼠的移動基本上都會是 2~3 的像素。

    不過其實這個解析度的問題其實算還好,真正大的問題,是在於人的手部在空中,其實很難精確、穩定地移動,所以在這方面造成的滑鼠游標移動的誤差,問題會更大。而解決的方法呢?大概就只能加大畫面上要操作的所有按鈕了∼也就是說,在實務上,一般 Windows 的程式,其實不太適合觸控、或是體感的操作,真正要方便使用,應該還是得重新設計圖形介面的。

而基本上,這就是目前這個使用 OpenNI 手部追蹤實作出來、不太實用的虛擬滑鼠了∼之後有空,應該會試著自己想辦法改方法來判斷滑鼠左鍵的觸發條件吧∼至於右鍵和滾輪?再說吧 XD

34 thoughts on “OpenNI 的手部追蹤、外加簡單的滑鼠模擬”

  1. 因为我用openni的版本不同,请问release和shutdown有什么区别吗

  2. 兩者的目的是相同的。
    為了介面統一性,建議改用 Release()。

    Shutdown() 是早期版本所使用的功能,目前已經被列為 deprecated 的介面。

  3. heresy大大你好,请问如果不使用NITE,是否可以实现双手的侦测与跟踪?

  4. to raining
    目前 OpenNI 應該還是只有 NITE 這套 middleware 可以用,如果不使用 NITE 的話,OpenNI 只能取得原始資料而已。
    在這個情況下,就是所有的演算法都得自己寫了。

  5. INCLUDEPATH = E:OpenCV210includeopencv C:OpenNIInclude

    LIBS = E:OpenCV210libcv210d.lib
    E:OpenCV210libcvaux210d.lib
    E:OpenCV210libcxcore210d.lib
    E:OpenCV210libhighgui210d.lib C:OpenNILib
    这是我的配置,但是当我构建工程的时候老是出现错误
    :-1: 错误:LNK1104: 无法打开文件“C:OpenNILib.obj”

    请问你是如何配置的啊

  6. INCLUDEPATH = E:/OpenCV210/include/opencv C:OpenNIInclude

    LIBS = E:/OpenCV210/libcv210d.lib
    E:/OpenCV210/libcvaux210d.lib
    E:/OpenCV210/libcxcore210d.lib
    E:/OpenCV210/libhighgui210d.lib C:OpenNILib
    这是我的配置,但是当我构建工程的时候老是出现错误
    :-1: 错误:LNK1104: 无法打开文件“C:OpenNILib.obj”

    请问你是如何配置的啊

  7. 大大你好,請問NITE的技術文檔除了安裝目錄下的4個還有更詳細深入的嗎

  8. to raining
    不知道你要的是什麼?不過官方有提供的應該就是那些檔案。

  9. 多謝,只是覺得那些檔案僅僅介紹了最基本的知識,想深入研究似乎不太夠

  10. 如果只是要使用,他提供的文件應該算是夠了。
    如果是想要自己寫出類似的功能,那要找的應該會是研討會論文之類的東西了。

  11. 您好,我想請問一下
    在這邊pPosition的點是否就是NiHandTracker範本所抓到的點呢?
    如果是的話如果我想把這個點疊到我的深度影像上應該在哪邊下手呢?
    不好意思 麻煩可以提點我一下 謝謝

  12. 不好意思,事實上我就是希望有辦法做到像NiHandTracker範本那樣,現在我有辦法秀出影像深度,想要能夠抓到手的質心或掌心並且得到他的座標,並且在深度影像上標出他的位置

  13. to 初學者
    基本上 Heresy 沒去跑過 NiHandTracker 這個程式。
    但是這邊所取得的手部位置,都只是用單一個點、來代表手部位置;Heresy 不確定他是否為手的質心或掌心,但是至少算是足以代表手的位置的點。

    至於你說你希望在深度影像上標出它的位置…這個問題基本上已經不是 OpenNI 的問題,而是顯示上的問題了。要怎麼做,要看你到底是用哪個圖形函式庫,來做顯示的動作。

  14. 好的 非常感謝您的回應! 我再來嘗試看看好了 謝謝

  15. 您好
    不好意思想請教一下
    我如果想要在main function 內使用callback function內的pPosition的值我應該讀取哪個位置的值呢?

  16. to 初學者

    抱歉,不確定你的問題?
    所謂 callback function 一般不是自己去呼叫的,而是當事件觸發時、會由系統執行到的程式。

  17. 抱歉….
    沒有講清楚
    就是在我揮動手勢後觸發到callback function
    而我在HandUpdate的callback function內可以得到手的位置 pPosition
    而我希望可以在main function內使用這個pPosition的值
    這樣子有可能實現嗎?
    還是我只能在callback function內對這個值做處理?
    不好意思 在請您指點一下

  18. to 初學者

    沒理解錯的話,你要做的事情,只要宣告一個全域變數,然後再 callback 裡面去設定他的值,這樣 main 應該就可以讀取了?

  19. 您好,請問,手部FocusGesture產生后,追蹤開始得到的是ID是手的ID,有什麽辦法能將手的ID和用戶User的ID聯繫起來么?

  20. to Tony

    OpenNI 並沒有直接提供可以對應的函式,不過應該可以透過 userGenerator,去確認手部現在的所在位置,是屬於哪個 user

  21. 謝謝您的解答,但能具體一點么?是比對得到ID的手的深度圖,和userGenerator產生的用戶的UserPixels么?卡住了,沒有頭緒目前。

  22. to Tony

    既然已經有手部的位置了,就可以透過這個位置(要從世界座標系統轉換到投影座標系統),到 UserGenerator 產生的 user map 上,查到這個點所在的位置,是屬於哪一個 user 的。

  23. 没有添加Click手势,识别不出来…弄了大半天…
    mNodes.mGesture.AddGesture( mNodes.sGestureToPress, NULL );

發佈留言

發佈留言必須填寫的電子郵件地址不會公開。 必填欄位標示為 *