承接上一篇「骨骼追蹤進階(上)」,本文將深入探討如何善用骨骼資料中的深度值(Z 軸)打造更立體的互動體驗,並詳細介紹姿勢識別的核心演算法與實作技巧。最後我們將把前一篇的「西蒙說」遊戲升級,改用姿勢識別取代簡單的碰撞測試,展示更自然、更豐富的自然用戶介面(NUI)開發模式。
過去幾篇文章中,我們僅使用關節點的 X、Y 座標進行二維定位。但 Kinect 提供的每個關節點都包含深度資訊(Z 值),這正是 Kinect 與傳統滑鼠、觸控板最大的差異所在。善用深度值,能讓應用程式呈現偽 3D 效果、根據遠近縮放 UI 元素,甚至實現真正的立體互動邏輯。
WPF 的 Canvas 容器支援 Canvas.ZIndex 屬性,可控制元素在垂直於螢幕方向的堆疊順序。ZIndex 越大,視覺上越接近使用者。我們可以將 Kinect 的深度值(最大約 13.4 英尺)反向映射為 0~1340 的 ZIndex 範圍:
int zIndex = (int)(1340 - depthInFeet * 100);
如此一來,當使用者的手靠近 Kinect 時,對應的手型圖示會浮現在參考圓環的前方;手遠離時則沉入後方,直觀呈現空間深度。
除了層次,深度值還可驅動 ScaleTransform 縮放手型圖示。使用者手部越靠近感測器,圖示越大,彷彿真實的手在眼前放大;反之則縮小。核心程式碼如下:
private void TrackHand(Joint hand, FrameworkElement cursorElement,
ScaleTransform cursorScale, FrameworkElement container, bool isLeft)
{
if (hand.TrackingState != JointTrackingState.NotTracked)
{
double z = hand.Position.Z * FeetPerMeters; // 轉換為英尺
cursorElement.Visibility = Visibility.Visible;
// 將關節點座標映射至 UI 容器
Point cursorCenter = new Point(cursorElement.ActualWidth / 2.0,
cursorElement.ActualHeight / 2.0);
Point jointPoint = GetJointPoint(this.KinectDevice, hand,
container.RenderSize, cursorCenter);
Canvas.SetLeft(cursorElement, jointPoint.X);
Canvas.SetTop(cursorElement, jointPoint.Y);
// 設定 ZIndex:深度越小(越近)ZIndex 越大
Canvas.SetZIndex(cursorElement, (int)(1200 - (z * 100)));
// 縮放比例:12 / z 可根據實際測試微調
cursorScale.ScaleX = 12 / z * (isLeft ? -1 : 1); // 左手鏡像
cursorScale.ScaleY = 12 / z;
// 偵錯用:顯示雙手深度
if (hand.JointType == JointType.HandLeft)
DebugLeftHand.Text = String.Format("左手: {0:0.00} 英尺", z);
else
DebugRightHand.Text = String.Format("右手: {0:0.00} 英尺", z);
}
else
{
DebugLeftHand.Text = DebugRightHand.Text = String.Empty;
}
}

開發提示: 縮放比例(12 / z)需經實測調整,以達到「手型圖示大小 ≈ 使用者實際手掌大小」的最佳體驗。若需更精確的手部尺寸,可考慮搭配感應手套等外部裝置。
姿勢識別相對單純、穩定,適合作為遊戲指令、選單觸發等離散式互動;手勢識別則適合連續操控。本文聚焦於姿勢識別。
判斷兩個關節點(如手腕與髖關節)的歐氏距離是否小於門檻值。適用於「叉腰」、「雙手舉高」等相對位置固定的姿勢。
任取三個關節點構成三角形,利用餘弦定理計算夾角。例如「展示肱二頭肌」姿勢:肩─肘─腕形成銳角三角形,夾角特徵明顯。

夾角計算公式:
cos θ = (a² + b² - c²) / (2ab)
θ = acos(cos θ) * 180 / π
實作時可採「三點法」(直接用三關節點)或「兩點法」(以肘為原點,另取一水平參考點),視姿勢限制條件而定。
使用者無法完美複製標準姿勢,因此需設定 Threshold 容許誤差。判斷邏輯:
bool isMatch = (targetAngle - threshold <= actualAngle)
&& (actualAngle <= targetAngle + threshold);
並處理角度跨越 0°/360° 邊界的情況(如 350° 與 10° 實際僅差 20°)。
public class PoseAngle
{
public JointType CenterJoint { get; private set; } // 夾角頂點
public JointType AngleJoint { get; private set; } // 另一邊關節
public double Angle { get; private set; } // 目標夾角
public double Threshold { get; private set; } // 容許誤差
public PoseAngle(JointType center, JointType angle, double a, double t)
{
CenterJoint = center; AngleJoint = angle; Angle = a; Threshold = t;
}
}
public struct Pose
{
public string Title;
public PoseAngle[] Angles;
}
private Pose[] poseLibrary;
private Pose startPose;
private void PopulatePoseLibrary()
{
poseLibrary = new Pose[4];
// 起始姿勢:T 型雙臂展開
startPose = new Pose { Title = "Start Pose", Angles = new PoseAngle[4] };
startPose.Angles[0] = new PoseAngle(JointType.ShoulderLeft, JointType.ElbowLeft, 180, 20);
startPose.Angles[1] = new PoseAngle(JointType.ElbowLeft, JointType.WristLeft, 180, 20);
startPose.Angles[2] = new PoseAngle(JointType.ShoulderRight, JointType.ElbowRight, 0, 20);
startPose.Angles[3] = new PoseAngle(JointType.ElbowRight, JointType.WristRight, 0, 20);
// 姿勢 1:雙手舉高
poseLibrary[0] = new Pose { Title = "舉起手來", Angles = new PoseAngle[4] };
poseLibrary[0].Angles[0] = new PoseAngle(JointType.ShoulderLeft, JointType.ElbowLeft, 180, 20);
poseLibrary[0].Angles[1] = new PoseAngle(JointType.ElbowLeft, JointType.WristLeft, 90, 20);
poseLibrary[0].Angles[2] = new PoseAngle(JointType.ShoulderRight, JointType.ElbowRight, 0, 20);
poseLibrary[0].Angles[3] = new PoseAngle(JointType.ElbowRight, JointType.WristRight, 90, 20);
// 姿勢 2:雙手放下
poseLibrary[1] = new Pose { Title = "把手放下來", Angles = new PoseAngle[4] };
poseLibrary[1].Angles[0] = new PoseAngle(JointType.ShoulderLeft, JointType.ElbowLeft, 180, 20);
poseLibrary[1].Angles[1] = new PoseAngle(JointType.ElbowLeft, JointType.WristLeft, 270, 20);
poseLibrary[1].Angles[2] = new PoseAngle(JointType.ShoulderRight, JointType.ElbowRight, 0, 20);
poseLibrary[1].Angles[3] = new PoseAngle(JointType.ElbowRight, JointType.WristRight, 270, 20);
// 姿勢 3:左手舉起、右手放下
poseLibrary[2] = new Pose { Title = "舉起左手", Angles = new PoseAngle[4] };
poseLibrary[2].Angles[0] = new PoseAngle(JointType.ShoulderLeft, JointType.ElbowLeft, 180, 20);
poseLibrary[2].Angles[1] = new PoseAngle(JointType.ElbowLeft, JointType.WristLeft, 90, 20);
poseLibrary[2].Angles[2] = new PoseAngle(JointType.ShoulderRight, JointType.ElbowRight, 0, 20);
poseLibrary[2].Angles[3] = new PoseAngle(JointType.ElbowRight, JointType.WristRight, 270, 20);
// 姿勢 4:右手舉起、左手放下
poseLibrary[3] = new Pose { Title = "舉起右手", Angles = new PoseAngle[4] };
poseLibrary[3].Angles[0] = new PoseAngle(JointType.ShoulderLeft, JointType.ElbowLeft, 180, 20);
poseLibrary[3].Angles[1] = new PoseAngle(JointType.ElbowLeft, JointType.WristLeft, 270, 20);
poseLibrary[3].Angles[2] = new PoseAngle(JointType.ShoulderRight, JointType.ElbowRight, 0, 20);
poseLibrary[3].Angles[3] = new PoseAngle(JointType.ElbowRight, JointType.WristRight, 90, 20);
}
private bool IsPose(Skeleton skeleton, Pose pose)
{
bool isPose = true;
for (int i = 0; i < pose.Angles.Length && isPose; i++)
{
double angle = GetJointAngle(skeleton.Joints[pose.Angles[i].CenterJoint],
skeleton.Joints[pose.Angles[i].AngleJoint]);
double target = pose.Angles[i].Angle;
double thresh = pose.Angles[i].Threshold;
double lo = target - thresh, hi = target + thresh;
// 處理 0/360 邊界
if (hi >= 360 || lo < 0)
{
lo = (lo < 0) ? 360 + lo : lo;
hi = hi % 360;
isPose = !(lo > angle && angle > hi); // 角度落在「環狀區間」外才判定失敗
}
else
{
isPose = (lo <= angle && angle <= hi);
}
}
return isPose;
}
private double GetJointAngle(Joint centerJoint, Joint angleJoint)
{
// 將關節點投影至 UI 座標系(便於偵錯視覺化)
Point p1 = GetJointPoint(this.KinectDevice, centerJoint, this.LayoutRoot.RenderSize, new Point());
Point p2 = GetJointPoint(this.KinectDevice, angleJoint, this.LayoutRoot.RenderSize, new Point());
// 建構參考點:以 p1 為原點,X 軸正方向
Point xRef = new Point(p1.X + Math.Abs(p2.X - p1.X), p1.Y);
double a = Distance(p1, p2);
double b = Distance(p1, xRef);
double c = Distance(p2, xRef);
double rad = Math.Acos((a * a + b * b - c * c) / (2 * a * b));
double deg = rad * 180 / Math.PI;
// 依 Y 軸關係判斷象限
if (p1.Y < p2.Y) deg = 360 - deg;
return deg;
}
private double Distance(Point a, Point b)
=> Math.Sqrt(Math.Pow(a.X - b.X, 2) + Math.Pow(a.Y - b.Y, 2));
使用 DispatcherTimer 限制玩家擺姿勢時間(預設 10 秒),超時即判定失敗。
private DispatcherTimer poseTimer;
public MainWindow()
{
// ... 既有初始化 ...
this.poseTimer = new DispatcherTimer();
this.poseTimer.Interval = TimeSpan.FromSeconds(10);
this.poseTimer.Tick += (s, e) => ChangePhase(GamePhase.GameOver);
this.poseTimer.Stop();
}
// 玩家執行階段
private void ProcessPlayerPerforming(Skeleton skeleton)
{
int seqIndex = this.instructionSequence[this.instructionPosition];
if (IsPose(skeleton, this.poseLibrary[seqIndex]))
{
this.poseTimer.Stop();
this.instructionPosition++;
if (this.instructionPosition >= this.instructionSequence.Length)
ChangePhase(GamePhase.SimonInstructing); // 進入下一輪
else
{
// TODO: 給予正確回饋(音效、動畫)
this.poseTimer.Start(); // 重置計時器,進入下一指令
}
}
}

將姿勢識別邏輯封裝為獨立元件:
PoseEngine 類別:內含 PoseDetected 事件、Poses 集合(支援 Add/Remove)、監聽 SkeletonFrameReady 逐幀判斷。
本文完整示範了如何:
骨骼追蹤系列到此告一段落。下一系列將進入手勢識別領域,探討動態軌跡分析、機器學習分類器應用等進階主題,敬請期待!
作者:yangecnu(博客園)|原文連結:https://www.cnblogs.com/yangecnu/