首页 > 代码库 > Kinect 开发 —— 常见手势识别(下)
Kinect 开发 —— 常见手势识别(下)
划动(Swipe)
划动手势和挥手(wave)手势类似。识别划动手势需要不断的跟踪用户手部运动,并保持当前手的位置之前的手的位置。因为手势有一个速度阈值,我们需要追踪手运动的时间以及在三维空间中的坐标。下面的代码展示了存储手势位置点的X,Y,Z坐标以及时间值。如果熟悉图形学中的矢量计算,可以将这个认为是一个四维向量。将下面的结构添加到类库中。
public struct GesturePoint { public double X { get; set; } public double Y { get; set; } public double Z { get; set; } public DateTime T { get; set; } public override bool Equals(object obj) { var o = (GesturePoint)obj; return (X == o.X) && (Y == o.Y) && (Z == o.Z)&&(T==o.T); } public override int GetHashCode() { return base.GetHashCode(); } }
我们将在KinectCursorManager对象中实现划动手势识别的逻辑,这样在后面的磁吸幻灯片按钮中就可以复用这部分逻辑。实现代码如下,代码中为了支持划动识别,需要向KinectCurosrManager对象中添加几个字段。GesturePoints集合存储路径上的所有点,虽然我们会一边移除一些点然后添加新的点,但是该集合不可能太大。SwipeTime和swipeDeviation分别提供了划动手势经历的时间和划动手势在y轴上的偏移阈值。划动手势经历时间过长和划动手势路径偏移y值过大都会使得划动手势识别失败。我们会移除之前的路径上的点,然后添加新的划动手势上的点。SwipeLength提供了连续划动手势的阈值。我们提供了两个事件来处理划动手势识别成功和手势不合法两种情况。考虑到这是一个纯粹的手势,与GUI界面无关,所以在实现过程中不会使用click事件。
xOutOfBoundsLength和initialSwipeX用来设置划动手势的开始位置。通常,我们并不关心挥划动手势的开始位置,只用在gesturePoints中寻找一定数量连续的点,然后进行模式匹配就可以了。但是有时候,我们只从某一个划动开始点来进行划动识别也很有用。例如如果在屏幕的边缘,我们实现水平滚动,在这种情况下,我们需要一个偏移阈值使得我们可以忽略在屏幕外的点,因为这些点不能产生手势。
private List<GesturePoint> gesturePoints; private bool gesturePointTrackingEnabled; private double swipeLength, swipeDeviation; private int swipeTime; public event KinectCursorEventHandler swipeDetected; public event KinectCursorEventHandler swipeOutofBoundDetected; private double xOutOfBoundsLength; private static double initialSwipeX;
下面的代码展示了一些帮助方法以及公共属性来管理手势追踪。GesturePointTrackingInitialize方法用来初始化各种手势追踪的参数。初始化好了划动手势之后,需要调用GesturePointTrackingStart方法。自然需要一个相应的GesturePointTrackingStop方法来结束挥动手势识别。最后我们需要提供两个重载的帮助方法ResetGesturePoint来管理一系列的我们不需要的手势点。
public void GesturePointTrackingInitialize(double swipeLength, double swipeDeviation, int swipeTime, double xOutOfBounds) { this.swipeLength = swipeLength; this.swipeDeviation = swipeDeviation; this.swipeTime = swipeTime; this.xOutOfBoundsLength = xOutOfBounds; } public void GesturePointTrackingStart() { if (swipeLength + swipeDeviation + swipeTime == 0) throw new InvalidOperationException("挥动手势识别参数没有初始化!"); gesturePointTrackingEnabled = true; } public void GesturePointTrackingStop() { xOutOfBoundsLength = 0; gesturePointTrackingEnabled = false; gesturePoints.Clear(); } public bool GesturePointTrackingEnabled { get { return gesturePointTrackingEnabled ; } } private void ResetGesturePoint(GesturePoint point) { bool startRemoving = false; for (int i= gesturePoints.Count; i >=0; i--) { if (startRemoving) gesturePoints.RemoveAt(i); else if (gesturePoints[i].Equals(point)) startRemoving = true; } } private void ResetGesturePoint(int point) { if (point < 1) return; for (int i = point-1; i >=0; i--) { gesturePoints.RemoveAt(i); } }
ResetGesturePoint 从下划姿势的底部点向上遍历,不等于point(传入的匹配结点)的保留 —— 下划过程的全部点,等于以及以上的点无效,全部删除 ~~
划动(swipe)手势识别的核心算法在HandleGestureTracking方法中,代码如下。将KinectCursorManager中的UpdateCursor方法和Kinect中的骨骼追踪事件绑定。每一次当获取到新的坐标点时,HandGestureTracking方法将最新的GesturePoint数据添加到gesturePoints集合中去。然后执行一些列条件检查,首先判断新加入的点是否以手势开始位置为起点参考,偏离Y轴过远。如果是,抛出一个超出范围的事件,然后将所有之前累积的点清空,然后开始下一次的划动识别。其次,检查手势开始的时间和当前的时间,如果时间差大于阈值,那么移除开始处手势点,然后将紧接着的点作为手势识别的起始点。如果新的手的位置在这个集合中,就很好。紧接着,判断划动起始点的位置和当前位置的X轴上的距离是否超过了连续划动距离的阈值,如果超过了,则触发SwipeDetected事件,如果没有,我们可以有选择性的判断,当前位置的X点是否超过了划动识别的最大区间返回,然后触发对于的事件。然后我们等待新的手部点传到HandleGestureTracking方法中去。
private void HandleGestureTracking(float x, float y, float z){ if (!gesturePointTrackingEnabled) return; // check to see if xOutOfBounds is being used if (xOutOfBoundsLength != 0 && initialSwipeX == 0) { initialSwipeX = x; } GesturePoint newPoint = new GesturePoint() { X = x, Y = y, Z = z, T = DateTime.Now }; gesturePoints.Add(newPoint); GesturePoint startPoint = gesturePoints[0]; var point = new Point(x, y); //check for deviation if (Math.Abs(newPoint.Y - startPoint.Y) > swipeDeviation) { //Debug.WriteLine("Y out of bounds"); if (swipeOutofBoundDetected != null) swipeOutofBoundDetected(this, new KinectCursorEventArgs(point) { Z = z, Cursor = cursorAdorner }); ResetGesturePoint(gesturePoints.Count); return; } if ((newPoint.T - startPoint.T).Milliseconds > swipeTime) //check time { gesturePoints.RemoveAt(0); startPoint = gesturePoints[0]; } if ((swipeLength < 0 && newPoint.X - startPoint.X < swipeLength) // check to see if distance has been achieved swipe left || (swipeLength > 0 && newPoint.X - startPoint.X > swipeLength)) // check to see if distance has been achieved swipe right { gesturePoints.Clear(); //throw local event if (swipeDetected != null) swipeDetected(this, new KinectCursorEventArgs(point) { Z = z, Cursor = cursorAdorner }); return; } if (xOutOfBoundsLength != 0 && ((xOutOfBoundsLength < 0 && newPoint.X - initialSwipeX < xOutOfBoundsLength) // check to see if distance has been achieved swipe left || (xOutOfBoundsLength > 0 && newPoint.X - initialSwipeX > xOutOfBoundsLength)) ) { if (swipeOutofBoundDetected != null) swipeOutofBoundDetected(this, new KinectCursorEventArgs(point) { Z = z, Cursor = cursorAdorner }); }}
磁性幻灯片
他比磁性按钮好的地方就是,不需要用户等待一段时间。在Xbox游戏中,没有人愿意去等待。而下压按钮又有自身的缺点,最主要的是用户体验不是很好。磁性幻灯片和磁性按钮一样,一旦用户进入到按钮的有效区域,光标就会自定锁定到某一点上。但是在这一点上,可以有不同的表现。除了悬停在按钮上方一段时间触发事件外,用户可以划动收来激活按钮。
从编程角度看,磁性幻灯片基本上是磁性按钮和划动手势(swipe)的组合。要开发一个磁性幻灯片按钮,我们可以简单的在可视化树中的悬浮按钮上声明一个计时器,然后再注册滑动手势识别事件。下面的代码展示了磁性幻灯片按钮的基本结构。其构造函数已经在基类中为我们声明好了计时器。InitializeSwipe和DeinitializeSwipe方法负责注册KinectCursorManager类中的滑动手势识别功能。
另外,我们也需要将控件的滑动手势的初始化参数暴露出来,这样就可以根据特定的需要进行设置了。下面的代码展示了SwipeLength和XOutOfBoundsLength属性,这两个都是默认值的相反数。这是因为磁性幻灯片按钮一般在屏幕的右侧,需要用户向左边划动,因此,相对于按钮位置的识别偏移以及边界偏移是其X坐标轴的相反数。public class MagneticSlide:MagnetButton{ private bool isLookingForSwipes; public MagneticSlide() { base.isLockOn = false; } private void InitializeSwipe() { if (isLookingForSwipes) return; var kinectMgr = KinectCursorManager.Instance; kinectMgr.GesturePointTrackingInitialize(SwipeLength, MaxDeviation, MaxSwipeTime, xOutOfBoundsLength); kinectMgr.swipeDetected += new KinectCursorEventHandler(kinectMgr_swipeDetected); kinectMgr.swipeOutofBoundDetected += new KinectCursorEventHandler(kinectMgr_swipeOutofBoundDetected); kinectMgr.GesturePointTrackingStart(); } private void DeInitializeSwipe() { var KinectMgr = KinectCursorManager.Instance; KinectMgr.swipeDetected -= new KinectCursorEventHandler(kinectMgr_swipeDetected); KinectMgr.swipeOutofBoundDetected -= new KinectCursorEventHandler(kinectMgr_swipeOutofBoundDetected); KinectMgr.GesturePointTrackingStop(); isLookingForSwipes = false; }public static readonly DependencyProperty SwipeLengthProperty = DependencyProperty.Register("SwipeLength", typeof(double), typeof(MagneticSlide), new UIPropertyMetadata(-500d));public double SwipeLength{ get { return (double)GetValue(SwipeLengthProperty); } set { SetValue(SwipeLengthProperty, value); }}public static readonly DependencyProperty MaxDeviationProperty = DependencyProperty.Register("MaxDeviation", typeof(double), typeof(MagneticSlide), new UIPropertyMetadata(100d));public double MaxDeviation{ get { return (double)GetValue(MaxDeviationProperty); } set { SetValue(MaxDeviationProperty, value); }}public static readonly DependencyProperty XOutOfBoundsLengthProperty = DependencyProperty.Register("XOutOfBoundsLength", typeof(double), typeof(MagneticSlide), new UIPropertyMetadata(-700d));public double XOutOfBoundsLength{ get { return (double)GetValue(XOutOfBoundsLengthProperty); } set { SetValue(XOutOfBoundsLengthProperty, value); }}public static readonly DependencyProperty MaxSwipeTimeProperty = DependencyProperty.Register("MaxSwipeTime", typeof(int), typeof(MagneticSlide), new UIPropertyMetadata(300));public int MaxSwipeTime{ get { return (int)GetValue(MaxSwipeTimeProperty); } set { SetValue(MaxSwipeTimeProperty, value); }}
要实现磁性幻灯片按钮的逻辑,我们只需要处理基类中的enter事件,以及划动手势识别事件即可。我们不会处理基类中的leave事件,因为当用户做划动手势时,极有可能会不小心触发leave事件。我们不想破坏之前初始化好了的deactivate算法逻辑,所以取而代之的是,我们等待要么下一个划动识别成功,要么在关闭划动识别前划动手势超出识别范围。当探测到划动时,触发一个标准的click事件。
public static readonly RoutedEvent SwipeOutOfBoundsEvent = EventManager.RegisterRoutedEvent("SwipeOutOfBounds", RoutingStrategy.Bubble,typeof(KinectCursorEventHandler), typeof(KinectInput));public event RoutedEventHandler SwipeOutOfBounds{ add { AddHandler(SwipeOutOfBoundsEvent, value); } remove { RemoveHandler(SwipeOutOfBoundsEvent, value); }}void KinectMgr_swipeOutofBoundDetected(object sender, KinectCursorEventArgs e){ DeInitializeSwipe(); RaiseEvent(new KinectCursorEventArgs(SwipeOutOfBoundsEvent));}void KinectMgr_swipeDetected(object sender, KinectCursorEventArgs e){ DeInitializeSwipe(); RaiseEvent(new RoutedEventArgs(ClickEvent));}protected override void OnKinectCursorEnter(object sender, KinectCursorEventArgs e){ InitializeSwipe(); base.OnKinectCursorEnter(sender, e);}
垂直滚动条 (Vertical Scroll)
传统上,垂直滚动条一直是交互界面设计的一个禁忌。但是垂直滚动条在划动触摸界面中得到了很好的应用。所以Xbox和Sony PlayStation系统中都使用了垂直滚动条来构建菜单。Harmonix’s的《舞林大会》(Dance Central)这一系列游戏使用了垂直滚动条式的菜单系统。Dance Central第一次成功的使用了垂直滚动界面作为手势交互界面。在下面的手势交互图中,当用户抬起或者放下手臂时会使得屏幕的内容垂直滚动。胳膊远离身体,抬起手臂会使得屏幕或者菜单从下往上移动,放下手臂会使得从上往下移动。
当用户做划动手势时,在整个手的划动过程中会手的位置在水平方向会保持相对一致。这就使得如果想进行多次连续的划动手势时会产生一些问题。有时候会产生一些比较尴尬的场景,那就是会无意中撤销前一次的划动手势。例如,用户使用右手从右向左进行划动手势,使得页面会跳转到下一页,现在用户的右手在身体的左边位置了,然后用户想将手移动回原始的开始位置以准备下一次的从右向左的挥动手势。但是,如果用于依然保持手在水平位置大致一致的话,应用程序会探测到一次从左向右的划动操作然后又将界面切换到了之前的那一页。这就使得用户必须创建一个循环的运动来避免不必要的误读。更进一步,频繁的划动手势也容易使得用户疲劳,而垂直方向的划动也只会加剧这一问题。
但是垂直滚动条则不会有上述的这些用户体验上的缺点。他比较容易使用,对用户来说也更加友好,另外,用户也不需要为了保持手在水平或者垂直方向一致而导致的疲劳。从技术方面来讲,垂直滚动操作识别较划动识别简单。垂直滚动在技术上是一个姿势而不是手势。滚动操作的探测是基于当前手臂的位置而不是手臂的运动。滚动的方向和大小由手臂和水平方向的夹角来确定。下图演示了垂直滚动。
使用之前的姿势识别那篇文章中的内容,我们能够计算从用户的身体到肩部和手腕的夹角,定义一个角度区间作为中间姿势,当用户手臂在这一区间内时,不会产生任何动作,如上图中的,当手臂自然处于-5度或者355度时,作为偏移的零点。建议在实际开发中,将零点的偏移上下加上20度左右。当用户的手臂离开这一区域时,离开的夹角及变化的幅度根据需求而定。但是建议至少在0度区间上下有两个区间来表示小幅和大幅的增加。这使得能够更好的实现传统的人机交互界面中的垂直滚动条的逻辑
通用暂停按钮 (Universal Pause)
暂停按钮,通常作为引导手势或者退出手势,是微软建议在给用户提供引导时很少的几个手势之一。这个手势是通过将左臂保持和身体45度角来完成的。在很多Kinect的游戏中都使用到了这一手势,用来暂停动作或者唤出Xbox菜单。和本文之前介绍的手势不一样,这个手势并没有什么符号学上的含义,是一个认为设计的动作。通用暂停手势很容易实现,也不一定要限制手臂,并且不容易和其他手势混淆。