使用 Qt 顯示 OpenNI 的人體骨架

延續上一篇《使用 Qt GraphicsView 顯示 OpenNI 影像資料》,這邊繼續接著上次的範例往下寫;而這次主要加入的功能,則是 OpenNI 人體骨架的顯示了∼相關的程式細節、以及說明,請先參考之前的《透過 OpenNI / NITE 分析人體骨架(上)》和《透過 OpenNI / NITE 分析人體骨架(下)》,這邊的程式基本上會沿用這兩篇的程式碼的概念、再做一些修改。

為了做到這件事,所以之前定義的 COpenNICKinectReader 這兩個類別,都必須要做對應的修改;除此之外,Heresy 也又另外定義了一個繼承自 QGraphicsItemCSkelItem,用來紀錄、處理人體的骨架資料。

完整的程式原始碼請到 Heresy 的 SkyDrive 下載;而接下來,就是簡單的說明了∼

COpenNI 的部分

由於要進行人體的骨架分析與追蹤,需要使用到 xn::UserGenerator,所以自然除了在 COpenNI 的成員資料裡,要加上他的資料(這邊是名為 m_User 的變數),也必須要在 Initial() 裡,加上對他的初始化;而這邊主要的程式修改,則就是在建立完 Image Generator 和 Depth Generator 後,再繼續建立、並設定 User Generator 了∼

/* Initial OpenNI context and create nodes. */
bool Initial()
{
// Initial OpenNI Context
...
// create depth node
m_eResult = m_Depth.Create( m_Context );
if( CheckError( "Create Depth Generator Error" ) )
return false;

// create user node
m_eResult = m_User.Create( m_Context );
if( CheckError( "Create User Generator Error" ) )
return false;

// set nodes
m_eResult = m_Depth.GetAlternativeViewPointCap().SetViewPoint( m_Image );
CheckError( "Can't set the alternative view point on depth generator" );

XnCallbackHandle hUserCB;
m_User.RegisterUserCallbacks( CB_NewUser, NULL, NULL, hUserCB );

m_User.GetSkeletonCap().SetSkeletonProfile( XN_SKEL_PROFILE_ALL );
XnCallbackHandle hCalibCB;
m_User.GetSkeletonCap().RegisterToCalibrationComplete(
CB_CalibrationComplete, &m_User, hCalibCB );

XnCallbackHandle hPoseCB;
m_User.GetPoseDetectionCap().RegisterToPoseDetected(
CB_PoseDetected, &m_User, hPoseCB );

return true;
}

上面程式碼的黃底部分,就是新加入的部分。基本上,和之前《透過 OpenNI / NITE 分析人體骨架(上)》比較不一樣的是,新版的 OpenNI 在 Skeleton 和 Pose Detection 的 callback function register 上,做了一些介面的調整

本來 Skeleton Capability 是透過 RegisterCalibrationCallbacks() 來同時登記「開始校正」和「校正結束」這兩種事件的 callback function;不過在新版的 OpenNI 則是建議分別採用 RegisterToCalibrationStart()RegisterToCalibrationComplete() 來進行 callback function 的註冊。

同樣的狀況也發生在 Pose Detection 這個 Capability。他本來是使用 RegisterToPoseCallbacks() 來註冊「偵測到姿勢」和「姿勢結束」兩種 callback function;而現在則是建議分別使用 RegisterToPoseDetected()RegisterToOutOfPose() 來取代。

這些 API 的變更建議,基本上 Heresy 都是在編譯 OpenNI 的程式的時候,才注意到的;編譯有使用 OpenNI 舊式介面的城市的時候,編譯器會輸出警告訊息、建議更換成新的介面,感覺還滿貼心的(:p)。而上面的程式碼呢,就是針對這些建議修改後的結果了∼

而這邊 Heresy 就只有註冊三個必要的 callback function,分別是 CB_NewUser()CB_PoseDetected()CB_CalibrationComplete();這三者都是 COpenNI 裡的 static function,其內容分別如下:

static void XN_CALLBACK_TYPE CB_NewUser(
xn::UserGenerator& generator, XnUserID user, void* pCookie )
{
cout << "New user identified: " << user << endl;
generator.GetPoseDetectionCap().StartPoseDetection("Psi", user);
}
static void XN_CALLBACK_TYPE CB_CalibrationComplete(
xn::SkeletonCapability& skeleton, XnUserID user,
XnCalibrationStatus calibrationError, void* pCookie )
{
cout << "Calibration complete for user " <<  user << ", ";
if( calibrationError == XN_CALIBRATION_STATUS_OK )
{
cout << "Success" << endl;
skeleton.StartTracking( user );
}
else
{
cout << "Failure" << endl;
xn::UserGenerator* pUser = (xn::UserGenerator*)pCookie;
pUser->GetPoseDetectionCap().StartPoseDetection( "Psi", user );
}
}
static void XN_CALLBACK_TYPE CB_PoseDetected(
xn::PoseDetectionCapability& poseDetection, const XnChar* strPose,
XnUserID user, void* pCookie)
{
cout << "Pose " << strPose << " detected for user " <<  user << endl;
xn::UserGenerator* pUser = (xn::UserGenerator*)pCookie;
pUser->GetSkeletonCap().RequestCalibration( user, FALSE );
poseDetection.StopPoseDetection( user );
}

而由於內容基本上都和之前的大致相同,所以 Heresy 在這邊就不多做說明了∼

最後,由於之後在讀取人體骨架顯示的時候,會用到 user generator 和 depth generator,所以這邊還另外加入了 GetUserGenerator()GetDepthGenerator() 這兩個函式,分別會回傳 m_Userm_Depth,讓 COpenNI 外部也可以使用。


CSkelItem 的部分

CSkelItem 這個類別,是為了處理、並顯示 OpenNI 的骨架資料而寫的,他基本上是繼承自 QGraphicsItem、在 Qt 的 Graphics Scene 裡使用的物件,這部分的說明,可以參考之前的《建立自己的 QGraphicsItem》一文。

而為了處理人體骨架資料,所以它除了 QGraphicsItem 必要的 paint()boundingRect() 兩個函式外,另外也實作了建構子、UpdateSkeleton() 等函式。下面就是這個類別的程式碼內容:

/* Class for draw skeleton */
class CSkelItem : public QGraphicsItem
{
public:
/* Constructor */
CSkelItem( XnUserID& uid, COpenNI& rOpenNI )
: QGraphicsItem(), m_UserID( uid ), m_OpenNI( rOpenNI )
{
// build lines connection table
m_aConnection[0][0] = 0;
m_aConnection[0][1] = 1;
...
m_aConnection[14][0] = 13;
m_aConnection[14][1] = 14;
}
}

/* update skeleton data */
void UpdateSkeleton()
{
// read the position in real world
XnPoint3D JointsReal[15];
JointsReal[ 0] = GetSkeletonPos( XN_SKEL_HEAD );
JointsReal[ 1] = GetSkeletonPos( XN_SKEL_NECK );
...
JointsReal[13] = GetSkeletonPos( XN_SKEL_RIGHT_KNEE );
JointsReal[14] = GetSkeletonPos( XN_SKEL_RIGHT_FOOT );

// convert form real world to projective
m_OpenNI.GetDepthGenerator().ConvertRealWorldToProjective(
15, JointsReal, m_aJoints );
}

public:
COpenNI& m_OpenNI;
XnUserID m_UserID;
XnPoint3D m_aJoints[15];
int m_aConnection[15][2];

private:
QRectF boundingRect() const
{
QRectF qRect( m_aJoints[0].X, m_aJoints[0].Y, 0, 0 );
for( unsigned int i = 1; i < 15; i )
{
if( m_aJoints[i].X < qRect.left() )
qRect.setLeft( m_aJoints[i].X );
if( m_aJoints[i].X > qRect.right() )
qRect.setRight( m_aJoints[i].X );

if( m_aJoints[i].Y < qRect.top() )
qRect.setTop( m_aJoints[i].Y );
if( m_aJoints[i].Y > qRect.bottom() )
qRect.setBottom( m_aJoints[i].Y );
}
return qRect;
}

void paint( QPainter *painter,
const QStyleOptionGraphicsItem *option, QWidget *widget )
{
// set pen for drawing
QPen pen( QColor::fromRgb( 0, 0, 255 ) );
pen.setWidth( 3 );
painter->setPen( pen );

// draw lines
for( unsigned int i = 0; i < 15; i )
{
XnPoint3D &p1 = m_aJoints[ m_aConnection[i][0] ],
&p2 = m_aJoints[ m_aConnection[i][1] ];

painter->drawLine( p1.X, p1.Y, p2.X, p2.Y );
}

// draw joints
for( unsigned int i = 0; i < 15; i )
painter->drawEllipse(QPointF(m_aJoints[i].X,m_aJoints[i].Y), 5, 5 );
}

XnPoint3D GetSkeletonPos( XnSkeletonJoint eJointName )
{
// get position
XnSkeletonJointPosition mPos;
m_OpenNI.GetUserGenerator().GetSkeletonCap().GetSkeletonJointPosition(
m_UserID,eJointName,mPos );

// convert to XnPoint3D
return xnCreatePoint3D(mPos.position.X,mPos.position.Y,mPos.position.Z);
}
};

CSkelItem 有四個主要的 member data:m_OpenNIm_UserIDm_aJointsm_aConnection;其中,m_OpenNIm_UserID 是在建立 CSkelItem 的物件時必須要傳入的參數,並且讓 CSkelItem 可以在讀取骨架資料時使用的。

m_aJoints 則是一個大小為 15 的 XnPoint3D 陣列,分別儲存著 OpenNI / NITE 提供的十五個關節的座標;這邊的資料會在執行 UpdateSkeleton() 時被更新,詳細的資料讀取方法,之前已經有介紹過了,所以這邊不多做說明,請直接參考程式碼的內容。

不過這邊另外要注意的是,由於 Heresy 是要把它用 2D 的形式來畫,所以這邊紀錄的座標資料,要先透過 depth generator 的 ConvertRealWorldToProjective() 這個函式,把各關節的座標從 real world 座標系統轉換到 projective(投影)座標系統,這樣才能用在 2D 的顯示上。

另外,為了方便繪製,Heresy 還另外定義了一個名為 m_aConnection 的 int 二維陣列,代表關節之間的連結關係表;他基本上是一個 15 x 2 的陣列,代表整個人體骨架有十五條代表肢體的線、而每條線的兩個值,則代表是第幾個關節點(對應到 m_aJoints)。

m_aConnection 這個資料的內容都是固定的,一旦建立後就不需要去修改,目前是寫在建構子裡面。而實際上,由於這個表對所有 CSkelItem 的骨架資料都是共通的,所以其實是可以考慮改成 static member data、讓所有變數共用一份,不過這邊為了簡單化,就先這樣寫了∼

而最重要的繪製的程式碼,則是 paint() 的部分。由於是使用 Qt 的架構,所以自然就是使用 Qt 的 QPainter 來作畫了∼

在這邊的程式碼裡,第一個步驟就是先設定 QPainter 的畫筆,透過指定要它的顏色和筆的寬度(這邊是設定成藍色),來修改畫出來的效果。

接下來,則是根據 m_aConnection 這張表,透過迴圈的方法,把代表人體骨架的十五條線都畫出來;而由於只是簡單地示意用的,所以就是直接用 QPainter 內建的 drawLine() 來畫而已了∼

在畫出骨架的線條後,Heresy 則是再透過 QPainterdrawEllipse(),把 m_aJoints 的關節點用圓形畫出來了。而最後呈現的結果呢,大致上就會像右上方的圖一樣了∼如果希望可以顯示更多資訊的話,也可以自己再針對 CSkelItem 做調整、修改。


CKinectReader 的部分

為了要支援骨架資料的顯示,所以在 CKinectReader 的部分,也要做一些對應的修改。這邊 Heresy 主要是加入了型別是 vector<CSkelItem*> 的成員變數 m_vSkeleton,用來簡單地記錄、管理目前的骨架資料。

接下來,就是修改更新資料的 timerEvent() 這個函式了∼修改的內容,主要就是在之前更新影像資料的程式碼後面、繼續來處理骨架的資料了。其內容如下:

void timerEvent( QTimerEvent *event )
{
// Read OpenNI data
m_OpenNI.UpdateData();

// Read Image
...

// Read Skeleton
xn::UserGenerator& rUser = m_OpenNI.GetUserGenerator();
XnUInt16 nUsers = rUser.GetNumberOfUsers();
if( nUsers > 0 )
{
// get user id
XnUserID* aUserID = new XnUserID[nUsers];
rUser.GetUsers( aUserID, nUsers );

// get skeleton for each user
unsigned int counter = 0;
xn::SkeletonCapability& rSC = rUser.GetSkeletonCap();
for( int i = 0; i < nUsers; i )
{
// if is tracking skeleton
if( rSC.IsTracking( aUserID[i] ) )
{
counter;
if( counter > m_vSkeleton.size() )
{
// create new skeleton item
CSkelItem* pSkeleton = new CSkelItem( aUserID[i], m_OpenNI );
m_Scene.addItem( pSkeleton );
m_vSkeleton.push_back( pSkeleton );
pSkeleton->setZValue( 10 );
}
else
m_vSkeleton[ counter-1 ]->m_UserID = aUserID[i];

// update skeleton item data
m_vSkeleton[ counter-1 ]->UpdateSkeleton();
m_vSkeleton[ counter-1 ]->setVisible( true );
}
}
// hide un-used skeleton items
for( unsigned int i = counter; i < m_vSkeleton.size(); i )
m_vSkeleton[i]->setVisible( false );

// release user id area
delete [] aUserID;
}
}

基本上,主要的流程都和之前《透過 OpenNI / NITE 分析人體骨架(上)》的相同,不過之前在確認到 user 正被進行骨架追蹤時,只會印出單一關節點的座標,作為輸出的結果,而現在這個版本,則是必須要去建立、修改整個人體的骨架資料。

當確認到目前偵測到的使用者有骨架資料需要顯示時,這裡的第一個動作,是先確認 scene 裡面的 CSkelItem 物件是否夠用(透過判斷 m_vSkeleton 的大小)?如果數量不夠的話,就必須要建立一個新的、並放到 Scene 裡;而如果數量夠的話,則是強制去修改現有的 CSkelItem 物件的 m_UserID,讓他對應到目前的使用者。

而當確定有 CSkelItem 物件可以拿來操作之後,接下來就是呼叫 CSkelItemUpdateSkeleton() 這個函式,讓他去抓取 OpenNI 的骨架資料、進行更新了!

最後,當所有的 user 都處理完了以後,這邊則是在用一個迴圈,去把 scene 裡面剩下沒有用到的 CSkelItem、透過 setVisible() 這個函式設定成隱藏,讓他們不會被顯示出來,這樣就完成所有動作了。

也就是,以目前的程式來說,Heresy 再加入新的 CSkelItem 後,就算之後沒有用了,基本上也只是把他設定成隱藏不顯示,而是不會真的去刪除他的。而如果有新的使用者的骨架要顯示的話,則是會優先使用這些被閒置、隱藏的 CSkelItem 物件,等到真正不夠,再建立新的。而這樣的結果,就是實際上每一個 CSkelItem 並不是真正固定去對應特定的 user,而是在每次更新資料的時候,都有可能對應到不同的 user。

雖然 Heresy 本來是想把每一個 CSkelItem 固定對應到個別的 user,但是由於要做到那樣的管理,還需要多一些程式來做控制,所以就先跳過、暫時先寫成這樣了;如果之後還有要再繼續用的話,應該會再做修改、寫一個更好的管理系統吧。


這個範例就先寫到這了。基本上,算是補完之前的《透過 OpenNI / NITE 分析人體骨架(上)》和《透過 OpenNI / NITE 分析人體骨架(下)》的圖形輸出的部分了∼

而實際上,寫到這邊的時候,Heresy 也才發現,試圖把 COpenNICKinectReader 拆開,其實或多或少會有一些架構上的問題;當然,這些問題也都可以解決,但是要繼續寫下去,可能要認真考慮一下要不要改一下架構了。

再者,這個範例程式裡,其實 Heresy 自己對於 CSkelItem 的管理相當地不滿意…理論上,應該寫一個完善的管理器,直接對應到 OpenNI 的 callback function,來做使用者的新增、刪除,會是比較好的方法…但是相對的,要做到這些事,必須要多上不少東西,所以在這邊才暫時沒有這樣寫;就如同前面所說的,之後有空的話,或許會把這部分的功能補完吧?

4 thoughts on “使用 Qt 顯示 OpenNI 的人體骨架”

  1. 请问heresy老师

    这些关节除了名字和坐标不同没有其他的不同地方吧。

    为什么不用一个数组,然后循环赋值?

  2. 请问heresy老师,ConvertRealWorldToProjective这个函数在使用是时候有什么要注意的问题没有?

    我在使用的时候报错说是访问冲突。如果不用这个函数显示出来的左右是反的,我的右手是画面中的左手。代码如下。

    if( mSC.IsTracking( aUserID[i] ) )
    {

    XnSkeletonJointTransformation mJointTran;

    XnPoint3D JointsReal[15], JointsWord[15];

    mSC.GetSkeletonJoint(aUserID[i], XN_SKEL_HEAD,mJointTran);

    JointsReal[0]=mJointTran.position.position;
    …………..
    depthGenerator.ConvertRealWorldToProjective(15,JointsReal,JointsWord);
    }

  3. to 路过
    不知道你的「访问冲突」是指什麼?原文是「Access violation」嗎?
    如果是的話,那應該是你的記憶體配置有問題。建議請檢查在你呼叫 ConvertRealWorldToProjective() 的時候,你所指定的 XnPoints 的陣列記憶體是否已經配置好、大小是否和指定的大小相符。

發佈回覆給「heresy」的留言 取消回覆

發佈留言必須填寫的電子郵件地址不會公開。