当前位置:首页 >> 信息与通信 >>

Kinect for Windows SDK开发入门


http://blog.csdn.net/denghp83/article/category/1422447 详情请点击这个网址! Kinect for Windows SDK 开发入门 目录(?)[-] Kinect for Windows SDK 开发入门一开发环境配置 Kinect 设备 Kinect for Windows SDK 软硬件环境 安装步骤 创建第一个 Kin

ect 项目 Kinect SDK 示例程序 结语 Kinect for Windows SDK 开发入门二基础知识 上 Kinect 传感器 发现连接的 Kinect 设备 打开传感器 停止传感器 彩色影像数据流 结语 Kinect for Windows SDK 开发入门三基础知识 下 性能改进 简单的图像处理 截图 ColorImageStream 对象图 获取数据的方式事件模式 VS 拉模式 结语 Kinect for Windows SDK 开发入门四景深数据处理 上 Kinect 传感器 Kinect 深度测量原理 深度影像增强 增强深度影像灰度级 深度数据的彩色渲染 结语 Kinect for Windows SDK 开发入门五景深数据处理 下 简单的景深影像处理 深度影像数据直方图 一些图像处理相关的知识 深度数据和游戏者索引位 对物体进行测量 深度值图像和视频图像的叠加 结语 Kinect for Windows SDK 开发入门六骨骼追踪基础 上 获取骨骼数据 骨骼对象模型 SkeletonStream 对象

SkeletonFrame Skeleton Joint 结语 Kinect for Windows SDK 开发入门七骨骼追踪基础 下 Kinect 连线游戏 游戏的用户界面 手部追踪 绘制游戏界面逻辑 游戏逻辑实现 进一步可改进地方 各种坐标空间及变换 空间变换 骨骼数据镜面对称 SkeletonViewer 自定义控件 结语 Kinect for Windows SDK 开发入门八骨骼追踪进阶 上 用户交互 WPF 应用程序中输入系统介绍 探测用户的交互 命中测试 Hit testing 响应输入 我说你做游戏 Simon say 设计一个用户界面 Simon say 构建程序的基础结构 Simon say 添加游戏基本元素 开始新游戏 更改游戏状态 显示 Simon 的指令 执行 Simon 的指令 需要改进的地方 用户体验 游戏内容 表现形式 结语 Kinect for Windows SDK 开发入门九骨骼追踪进阶 下 基于景深数据的用户交互 姿势 姿势识别 响应识别到的姿势

Simon Says 游戏中使用姿势识别 扩展与代码重构 结语 Kinect for Windows SDK 开发入门十手势识别 上基本概念 什么是手势 自然交互界面 NUI 手势从哪里来 实现手势识别 基于算法的手势识别 基于神经网络的手势识别 基于样本的识别 识别常见的手势 挥动 wave 手势 结语 Kinect for Windows SDK 开发入门十一手势识别 下基本手势识别 基本的手势追踪 剩余七种常见手势的识别 悬浮按钮 Hover Button 下压按钮 Push Button 磁性按钮 Magnet Button 划动 Swipe 磁性幻灯片 Magnetic Slide 垂直滚动条 Vertical Scroll 通用暂停按钮 Universal Pause 测试 Demo 手势识别的未来 结语 Kinect for Windows SDK 开发入门十二语音识别 上 麦克风阵列 MSR Kinect Audio 语音识别 获取音频数据 使用音频数据流 对音频数据进行处理 去除回声 结语 Kinect for Windows SDK 开发入门十三语音识别 下 使用定向麦克风进行波速追踪 Beam Tracking for a Directional Microphone 语音命令识别 结语 Kinect for Windows SDK 开发入门十四进阶指引 上 影像处理帮助类 Coding4Fun Kinect 工具类

创建自己的扩展方法库 创建一个 WPF 项目 创建类及扩展方法 创建其它的扩展方法 调用扩展方法 编写转换方法 近距离探测 Proximity Detection 简单的近距离探测 使用景深数据进行近距离探测 对近距离探测的改进 结语 Kinect for Windows SDK 开发入门十五进阶指引 下 运动识别 保存视频影像 面部识别 全息图 其他值得关注的类库和项目 结语 Kinect for Windows SDK 开发入门(一):开发环境配置 首先来看一下 Kinect 设备: 1. Kinect 设备 黑色的 Kinect 设备如下图:基座和感应器之间有一个电动的马达,通过程序能够调整俯仰 角度,在上面的感应器中有一个红外投影仪,两个摄像头,四个麦克风和一个风扇。打开外 面的盖子可以看到里面的构造:这些感应器用来捕捉 RGB 和深度数据,面对 Kinect,从左 往右看。 最左边是红外光源, 其次是 LED 指示灯, 再次是彩色摄像头, 用来收集 RGB 数据, 最右边是红外摄像头用才采集景深数据。彩色摄像头最大支持 1280*960 分辨率成像,红外 摄像头最大支持 640*480 成像。

在感应器的下方是麦克风阵列,他包括四个不同的麦克风,一个在左边的红外发射器下面, 另外 3 个在右边景深摄像头下面。 初步了解了 Kinect 构造后,接下来看看开发环境的搭建: 2. Kinect for Windows SDK 软硬件环境 Kinect for Windows SDK 是一些列的类库, 他能够使得开发者能够将 Kinect 作为输入设备开 发各种应用程序。就像名字所显示的那样,Kinect for Windows SDK 只能运行在 32 位或者 64 位的 windows7 及以上版本的操作系统上。 硬件配置 一台电脑,当然内存越大越好(推荐 2G 或者以上),处理器频率(2.66GHz 及以上)越高越好。 Windows 7 操作系统,以及支持 Microsoft DirectX9.0c 兼容的显卡 Kinect for Xbox360 Sensor(如果单买的话,自带有 USB Power Adapter,如果是和 Xbox360 套装一起捆绑的话,因为 Xbox 主机能够直接为 Kinect 外设提供电源,所以没有电源,可能 需要另买 USB Power Adapter) 使用 Visual Studio 2010 Express 版本或者专业版进行开发时, 需要安装最新版本的 Kinect for Windows SDK,SDK 中包含有对 Kinect 的硬件驱动。 软件配置 1. Visual Studio 2010 Express 或者 Visual Studio 2010 专业版或其他版本 2. .NET Framework 4.0 3. Kinect for Windows SDK, 最 新 版 本 为 1.0 版 本 , 下 载 地 址 : http://www.microsoft.com/en-us/kinectforwindows/develop/overview.aspx 3. 安装步骤 安装 SDK 之前,需要断开 Kinect 与电脑的连接,并关闭 Visual Studio。安装过程很简单, 等 SDK 安装完成之后,将 Kinect 电源线插上并连接到电脑上,Win7 会自动寻找和安装驱 动,安装完成后就可以识别 Kinect,这是 Kinect 上面 LED 指示灯会变成绿色。 看驱动是否安装成功,可以到电脑的设备管理器中查看,如下图:在 Microsoft Kinect 节点 下应该有 3 个项,分别是 Microsoft Kinect Audio Array Control,Microsoft Kinect Camera, 和 Microsoft Kinect Security Control. 查看 Kinect 麦克风是否安装成功可以在设备管理器的声音视频游戏控制器节点下查看,如

下图,Kinect USB Audio 应该在这个节点下面:

4. 创建第一个 Kinect 项目 创建一个 Kincet 项目通常需要: 1. 创建一个 VS 项目,一般为了展示通常创建一个 wpf 项目。 2. 添加 Microsoft.Kinect.dll 引用,如果是早期版本的 SDK,这个名称可能不同。 3. 引入 Kinect 命名空间。 Kinect 支持 3 中类型的托管应用程序,分别是:控制台应用程序,WPF 以及 Windows Form 应用程序。首先来创建一个 Windows 控制台应用程序,然后在 Main 函数所在的代码中引 入 Kinect 命名控件,代码如下: using Microsoft.Kinect; static void Main(string[] args) { //初始化 sensor 实例 KinectSensor sensor = KinectSensor.KinectSensors[0]; //初始化照相机 sensor.DepthStream.Enable(); sensor.DepthFrameReady += EventHandler<DepthImageFrameReadyEventArgs>(sensor_DepthFrameReady);

new

Console.ForegroundColor=ConsoleColor.Green; //打开数据流 sensor.Start(); while (Console.ReadKey().Key != ConsoleKey.Spacebar) { } } static void sensor_DepthFrameReady(object sender, DepthImageFrameReadyEventArgs e) { using (var depthFrame=e.OpenDepthImageFrame()) { if (depthFrame == null) return; short[] bits = new short[depthFrame.PixelDataLength]; depthFrame.CopyPixelDataTo(bits); foreach (var bit in bits) Console.Write(bit); } } 在上面的代码中, 为了从摄像头中获取数据流, 需要初始化 KinectSensor 对象, 然后启动他。 为了获取景深数据,我们给 sensor 的 DepthFrameReady 注册了时事件。上面的代码中数据 通 过 DepthFrameReady 事 件 触 发 。 在 Kinect 应 用 中 , 这 些 获 取 数 据 的 事 件 有 DepthFrameReady,ColorFrameReady 以及 SkeletonFrameReady。可以通过这些事件获取丰富 的数据来完成各种有趣的应用。 在上面的代码中, 我们简单的将景深摄像头获取的数据输出 打印到控制台上。运行程序,然后站在 Kinect 前面一段距离,你就会看到控制台上输出的 信息,酷吧。

5.Kinect SDK 示例程序 安装完 Kinect for Windows SDK 后,快捷菜单列表中有一个 Kinect SDK Sample Browser,自 带有一些示例应用程序,打开后如下图:

如果安装好了 Kinect,就可以直接点击运行了:我试了一下,挺有趣的: Kinect Explorer:这是一 WPF 程序,界面分为左右两个图像区域,左边通过彩色摄像头获取 图像信息, 并通过骨骼追踪将骨骼叠加在图像上, 右边图像是通过景深摄像头获取的景深信 息,也在图上叠加了骨骼信息,在这两幅图像下面是一些列对成像参数的设置。这个例子主 要是用来展示这几个传感器的应用以及如何获取传感器的信息,还包括获取骨骼数据。 Shape Game:这个一个简单的游戏,界面上的人是通过骨骼追踪绘制出来的,在 Kinect 前 面晃动时,界面上的人也会跟着动,当碰到图形时能够得分。 Kinect Audio Demo:这个是语音识别的例子上面展示的是声音的方向,下面是语音识别的 结果,有 Red,Green,Blue 三个单词,当站在 Kinect 前面说某个单词时,如果识别正确, 横线会显示相应的颜色。试了一下,还是挺灵敏的。后的的例子是针对 Kinect for Windows sensor 设备的例子,我的 Kinect for xbox Sensor 不能用。 6.结语 本文简要介绍了 Kinect 传感器的结构,开发所需要的软硬件环境,并通过一个小例子展示 了如何从 Kinect 获取数据,最后简单介绍了 Kinect SDK 所带的例子,希望本文能帮助你熟 悉 Kinect for windows SDK。 Kinect for Windows SDK 开发入门(二):基础知识 上 1. Kinect 传感器 基于 Kinect 开发的应用程序最开始需要用到的对象就是 KinectSensor 对象,该对象直接 表示 Kinect 硬件设备。KinectSensor 对象是我们想要获取数据,包括彩色影像数据,景深数 据和骨骼追踪数据的源头。本文将详细介绍 ColorImageStream ,后面的文章将详细讨论 DepthImageStream 和 SkeletonStream。 从 KinectSensor 获取数据最常用的方式是通过监听该对象的一系列事件。每一种数据流 都有对应的事件,当改类型数据流可用时,就会触发改时间。每一个数据流以帧(frame)为单 位。例如:ColorImageStream 当获取到了新的数据时就会触发 ColorFrameReady 事件。当在 讨论各个具体的传感器数据流是我们将会详细讨论这些事件。 每一种数据流(Color,Depth,Skeleton)都是以数据点的方式在不同的坐标系中显示的,在后 面的讨论中我们能够清楚的看到这一点。 将一个数据流中的点数据转换到另一个数据流中是 一个很常见的操作,在本文的后面将会讨论如何转换以及为什么这种转换很有必要。 KinectSensor 对 象 有 一 些 列 的 方 法 能 够 进 行 数 据 流 到 数 据 点 阵 的 转 换 , 他 们 是 MapDepthToColorImagePoint,MapDepthToSkeletonPoint 以及 MapSkeletonPointToDepth。在 获取 Kinect 数据前,我们必须先发现连接的 Kinect 设备。发现 Kinect 设备很简单,但是也 有需要主注意的地方。 1.1 发现连接的 Kinect 设备 KinectObject 对象没有公共的构造器,应用程序不能直接创建它。相反,该对象是 SDK 在探测到有连接的 Kinect 设备时创建的。当有 Kinect 设备连接到计算机上时,应用程序应 该得到通知或者提醒。KinectSeneor 对象有一个静态的属性 KinectSensors,该属性是一个 KinectSensorCollection 集合,该集合继承自 ReadOnlyCollection,ReadOnlyCollection 集合很 简单,他只有一个索引器和一个称之为 StatusChanged 的事件。 使用集合中的索引器来获取 KinectSensor 对象。集合中元素的个数就是 Kinect 设备的个 数。也就是说,一台电脑上可以连接多个 Kinect 设备来从不同的方向获取数据。应用程序

可以使用多个 Kinect 设备来获取多方面的数据,Kinect 个数的限制 只有电脑配置的限制。 由于每个 Kinect 是通过 USB 来进行数据传输的,所以每一个 Kinect 设备需要一条 USB 线 与电脑相连。此外,更多的 Kinect 设备需要更多的 CPU 和内存消耗。 查找 Kinect 设备可以通过简单的遍历集合找到;但是 KinectSensor 集合中的设备不是都 能直接使用,所以 KinectSensor 对象有一个 Status 属性,他是一个枚举类型,标识了当前 Kinect 设备的状态。下表中列出了传感器的状态及其含义:

只有设备在 Connected 状态下时,KinectSensor 对象才能初始化。在应用的整个生命周期 中, 传感器的状态可能会发生变化, 这意味着我们开发的应用程序必须监控设备的连接状态, 并且在设备连接状态发生变化时能够采取相应的措施来提高用户体验。例如,如果连接 Kinect 的 USB 线从电脑拔出,那么传感器的连接状态就会变为 Disconnected,通常,应用 程序在这种情况下应该暂停,并提示用户将 Kinect 设备插入到电脑上。应用程序不应该假 定在一开始时 Kinect 设备就处于可用状态,也不应该假定在整个程序运行的过程中,Kinect 设备会一直与电脑连接。 下面,首先创建一个 WPF 应用程序来展示如何发现,获取 Kinect 传感器的状态。先建按 一个 WPF 项目,并添加 Microsoft.Kinect.dll。在 MainWindows.xaml.cs 中写下如下代码: public partial class MainWindow : Window { //私有 Kinectsensor 对象 private KinectSensor kinect; public KinectSensor Kinect { get { return this.kinect;} set { //如果带赋值的传感器和目前的不一样 if (this.kinect!=value) { //如果当前的传感对象不为 null if (this.kinect!=null)

{ //uninitailize 当前对象 this.kinect=null; } //如果传入的对象不为空,且状态为连接状态 if (value!=null&&value.Status==KinectStatus.Connected) { this.kinect=value; } } } } public MainWindow() { InitializeComponent(); this.Loaded += (s, e) => DiscoverKinectSensor(); this.Unloaded += (s, e) => this.kinect = null; } private void DiscoverKinectSensor() { KinectSensor.KinectSensors.StatusChanged += KinectSensors_StatusChanged; this.Kinect = KinectSensor.KinectSensors.FirstOrDefault(x => x.Status KinectStatus.Connected); }

==

private void KinectSensors_StatusChanged(object sender, StatusChangedEventArgs e) { switch (e.Status) { case KinectStatus.Connected: if (this.kinect == null) this.kinect = e.Sensor; break; case KinectStatus.Disconnected: if (this.kinect == e.Sensor) { this.kinect = null; this.kinect = KinectSensor.KinectSensors.FirstOrDefault(x => x.Status == KinectStatus.Connected); if (this.kinect == null) { //TODO:通知用于 Kinect 已拔出

} } break; //TODO:处理其他情况下的状态 } } } 上面的代码注释很详细,首先定义了一个私有变量 kinect, 应用程序应该定义一个私有的 变量来存储对获取到的 KincectSensor 对象的引用,当应用程序不在需要 KinectSensor 产生 数据时,可以使用这个局部变量来释放对 KinectSensor 对象的引用从而释放资源。我们还定 义了一个 Kinect 属性来对这个私有变量进行包装,使用属性的目的是保证能够以正确的方 式初始化和反初始化 KinectSensor 对象。在 Set 方法中我们可以看到,自由待赋值的对象的 组航太是 Connected 的时候我们才进行赋值操作,任何将没有处在 Connected 状态的传感器 对象复制给 KinectSensor 对象时都会抛出 InvalidOperationException 异常。 在构造函数中有两个匿名方法,一个用来监听 Loaded 事件,一个用来监听 Unloaded 事 件 。 当 卸 载 时 应 该 将 Kinect 属 性 置 为 空 。 在 窗 口 的 Loaded 事 件 中 程 序 通 过 DiscoverKinectSensor 方法试图调用一个连接了的传感器。在窗体的 Loaded 和 Unloaded 事 件中注册这两个事件用来初始化和释放 Kinect 对象,如果应用程序没有找到 Kinect 对象, 将会通知用户。 DiscoverKinectSensor 方法只有两行代码, 第一行代码注册 StatusChanged 事件, 第二行代 码通过 lambda 表达式查询集合中第一个处在 Connected 状态的传感器对象,并将该对象复 制给 Kinect 属性。Kinect 属性的 set 方法确保能都赋值一个合法的 Kinect 对象。 StatusChanged 事件中值得注意的是,当状态为 KinectSensor.Connected 的时候,if 语句限制 了应用程序只能有一个 kinect 传感器,他忽略了电脑中可能连接的其他 Kinect 传感器。 以上代码展示了用于发现和引用 Kinect 设备的最精简的代码,随着应用的复杂,可能需 要更多的代码来保证线程安全以及能让垃圾回收器及时释放资源以防止内存泄露。 1.2 打开传感器 一旦发现了传感器,在应用程序能够使用传感器之前必须对其进行初始化。传感器的初 始化包括三个步骤。首先,应用程序必须设置需要使用的数据流,并将其状态设为可用。每 一中类型的数据流都有一个 Enable 方法,该方法可以初始化数据流。每一种数据流都完全 不同,在使用之前需要进行一些列的设置。在一些情况下这些设置都在 Enable 方法中处理 了。在下面,我们将会讨论如何初始化 ColorImageStream 数据流,在以后的文章中还会讨 论如何初始化 DepthImageStream 数据流和 SkeletonStream 数据流。 初始化之后,接下来就是要确定应用程序如何使用产生的数据流。最常用的方式是使用 Kinect 对象的一些列事件,每一种数据流都有对应的事件,他们是:ColorImageStream 对应 ColorFrameReady 事件、DepthImageStream 对应 DepthFrameReady 事件、SkeletonStream 对 象对应 SkeletonFrameReady 事件。以及 AllFramesReady 事件。各自对应的事件只有在对应 的数据流 enabled 后才能使用,AllFramesReady 事件在任何一个数据流状态 enabled 时就能 使用。 最后,应用程序调用 KinectSensor 对象的 Start 方法后,frame-ready 事件就会触发从而产 生数据。

1.3 停止传感器 一旦传感器打开后,可以使用 KinectSensor 对象的 Stop 方法停止。这样所有的数据产生 都会停止,因此在监听 frameready 事件时要先检查传感器是否不为 null。 KinectSensor 对象以及数据流都会使用系统资源, 应用程序在不需要使用 KinectSensor 对 象时必须能够合理的释放这些资源。在这种情况下,程序不仅要停止传单器,还用注销 frameready 事件。注意,不要去调用 KinectSensor 对象的 Dispose 方法。这将会阻止应用程 序再次获取传感器。应用程序必须从启或者将 Kinect 从新拔出然后插入才能再次获得并使 用对象。 2. 彩色影像数据流 Kinect 有两类摄像头,近红外摄像头和普通的视频摄像头。视频摄像头提供了一般摄像 头类似的彩色影像。这种数据流是三中数据流中使用和设置最简单的。因此我将他作为 Kinect 数据流介绍的例子。 使用 Kinect 数据流也有三部。首先是数据流必须可用。一旦数据流可用,应用程序就可 以从数据量中读取数据并对数据进行处理和展现。 一旦有新的数据帧可用, 这两个步骤就会 一直进行,下面的代码展现了如何初始化 ColorImage 对象。 public KinectSensor Kinect { get { return this.kinect;} set { //如果带赋值的传感器和目前的不一样 if (this.kinect!=value) { //如果当前的传感对象不为 null if (this.kinect!=null) { UninitializeKinectSensor(this.kinect); //uninitailize 当前对象 this.kinect=null; } //如果传入的对象不为空,且状态为连接状态 if (value!=null&&value.Status==KinectStatus.Connected) { this.kinect=value; InitializeKinectSensor(this.kinect); } } } } private void InitializeKinectSensor(KinectSensor kinectSensor) { if (kinectSensor != null) {

kinectSensor.ColorStream.Enable(); kinectSensor.ColorFrameReady += EventHandler<ColorImageFrameReadyEventArgs>(kinectSensor_ColorFrameReady); kinectSensor.Start(); } }

new

private void UninitializeKinectSensor(KinectSensor kinectSensor) { if (kinectSensor != null) { kinectSensor.Stop(); kinectSensor.ColorFrameReady -= new EventHandler<ColorImageFrameReadyEventArgs>(kinectSensor_ColorFrameReady); } } 上面的代码对之前 Kinect 属性进行了修改,加粗为修改部分。新添加的两行调用了两个 方法,分别初始化和释放 KinectSensor 和 ColorImageStream 对象。InitializeKinectSensor 对 象调用 ColorImageStream 的 Enable 方法,注册 ColorFrameReady 事件并调用 start 方法。一 旦打开了传感器,当新数据帧大道是就会触发 frameready 事件,该事件触发频率是每秒 30 次。 在实现 Kinect_ColorFrameReady 方法前,我们先在 XAML 窗体中添加一些空间来展现获取 到的数据,代码如下: <Window x:Class="KinectApplicationFoundation.MainWindow" xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation" xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" Title="ColorImageStreamFromKinect" Height="350" Width="525"> <Grid> <Image x:Name="ColorImageElement"></Image> </Grid> </Window> 然后,在 Kinect_ColorFrameReady 方法中,我们首先通过打开或者获取一个 frame 来提 取获 Frame 数据。ColorImageFrameReadyEventArgs 对象的 OpenColorImageFrame 属性返回 一个当前的 ColorImageFrame 对象。这个对象实现了 IDisposable 接口。所以可以将这个对 象抱在 using 语句中的原因,在提取像素数据之前需要使用一个 Byte 数组保存获取到的数据。 FrameObject 对象的 PixelDataLength 对象返回数据和序列的具体大小。 调用 CopyPixelDataTo 方法可以填充像素数据,然后将数据展示到 image 控件上,具体代码如下: void kinectSensor_ColorFrameReady(object sender, ColorImageFrameReadyEventArgs e) { using (ColorImageFrame frame = e.OpenColorImageFrame()) { if (frame != null) {

byte[] pixelData = new byte[frame.PixelDataLength]; frame.CopyPixelDataTo(pixelData); ColorImageElement.Source = BitmapImage.Create(frame.Width, frame.Height, 96, 96, PixelFormats.Bgr32, null, pixelData, frame.Width frame.BytesPerPixel); } } } 运行程序,就能得到从 Kinect 获取的视频信息,如下图所示这是从 Kinect 彩色摄像头获 取的我房间的照片。和一般的视频没什麽两样,只不过这个是从 Kinect 的视频摄像头产生 的。 *

3. 结语 本文简要介绍了 Kinect 开发会遇到的基本对象,Kinect 物理设备的发现,KinectSensor 对象的初始化, 打开 KinectSensor 对象以及如何获取数据流, 最后以 ColorImageStream 对象 为例展示了如何从 Kinect 获取数据并展现出来。 由于 Kinect 的彩色摄像头默认每秒产生 30 副 ColorImageFrame,所以上面的应用程序会 产生 30 个 Bitmap 对象,而且这些对象初始化后很快将变成垃圾等待垃圾回收器进行收集,

当采集的数据量很大时,将会对性能产生影响。限于篇幅原因,下篇文章将会介绍如何对这 一点进行改进,并将讨论获取 Kinect 传感器产生数据的两种编程模式:基于事件的模式和 轮询的模式。 Kinect for Windows SDK 开发入门(三):基础知识 下 1. 性能改进 上文的代码中,对于每一个彩色图像帧,都会创建一个新的 Bitmap 对象。由于 Kinect 视频摄像头默认采集频率为每秒 30 幅,所以应用程序每秒会创建 30 个 bitmap 对象,产生 30 次的 Bitmap 内存创建,对象初始化,填充像素数据等操作。这些对象很快就会变成垃圾 等待垃圾回收器进行回收。 对数据量小的程序来说可能影响不是很明显, 但当数据量很大时, 其缺点就会显现出来。 改进方法是使用 WriteableBitmap 对象。它位于 System.Windows.Media.Imaging 命名空间 下面,该对象被用来处理需要频繁更新的像素数据。当创建 WriteableBitmap 时,应用程序 需要指定它的高度,宽度以及格式,以使得能够一次性为 WriteableBitmap 创建好内存,以 后只需根据需要更新像素即可。 使用 WriteableBitmap 代码改动地方很小。下面的代码中,首先定义三个新的成员变量, 一个是实际的 WriteableBitmap 对象,另外两个用来更新像素数据。每一幅图像的大小都是 不变的,因此在创建 WriteableBitmap 时只需计算一次即可。 InitializeKinect 方法中加粗的部分是更改的代码。创建 WriteableBitmap 对象,准备接收像素 数据,图像的范围同时也计算了。在初始化 WriteableBitmap 的时候,同时也绑定了 UI 元素 (名为 ColorImageElement 的 Image 对象)。此时 WriteableBitmap 中没有像素数据,所以 UI 上是空的。 <pre class=code>private WriteableBitmap _ColorImageBitmap;private Int32Rect_ColorImageBitmapR ect;private int _ColorImageStride;private byte[] _ColorImagePixelData; if (kinectSensor != null) { ColorImageStream colorStream=kinectSensor.ColorStream; colorStream.Enable(); this.colorImageBitMap = new WriteableBitmap(colorStream.FrameWidth, colorStream.FrameHeight, 96, 96, PixelFormats.Bgr32, null); this.colorImageBitmapRect = new Int32Rect(0, 0, colorStream.FrameWidth, colorStream.FrameHeight); this.colorImageStride = colorStream.FrameWidth * colorStream.FrameBytesPerPixel; ColorImageElement.Source = this.colorImageBitMap; kinectSensor.ColorFrameReady += kinectSensor_ColorFrameReady; kinectSensor.Start(); } 还需要进行的一处改动是,对 ColorFrameReady 事件响应的代码。如下图。首先删除之 前创建 Bitmap 那部分的代码。 调用 WriteableBitmap 对象的 WritePixels 方法来更新图像。 方 法使用图像的矩形范围,代码像素数据的数组,图像的 Stride,以及偏移(offset).偏移量通常 设置为 0。

private void Kinect_ColorFrameReady(object sender, ColorImageFrameReadyEventArgs e) { using (ColorImageFrame frame = e.OpenColorImageFrame()) { if (frame != null) { byte[] pixelData = new byte[frame.PixelDataLength]; frame.CopyPixelDataTo(pixelData); this._ColorImageBitmap.WritePixels(this._ColorImageBitmapRect, pixelData, this._ColorImageStride, 0); } } } 基 于 Kinect 的 应 用 程 序 在 无 论 是 在 显 示 ColorImageStream 数 据 还 是 显 示 DepthImageStream 数据的时候,都应该使用 WriteableBitmap 对象来显示帧影像。在最好的 情况下,彩色数据流会每秒产生 30 帧彩色影像,这意味着对内存资源的消耗比较大。 WriteableBitmap 能够减少这种内存消耗,减少需要更新影响带来的内存开辟和回收操作。 毕竟在应用中显示帧数据不是应用程序的最主要功能, 所以在这方面减少内像存消耗显得很 有必要。 2. 简单的图像处理 每一帧 ColorImageFrame 都是以字节序列的方式返回原始的像素数据。应用程序必须以 这些数据创建图像。这意味这我们可以对这些原始数据进行一定的处理,然后再展示出来。 下面来看看如何对获取的原始数据进行一些简单的处理。 void kinectSensor_ColorFrameReady(object sender, ColorImageFrameReadyEventArgs e) { using (ColorImageFrame frame = e.OpenColorImageFrame()) { if (frame != null) { byte[] pixelData = new byte[frame.PixelDataLength]; frame.CopyPixelDataTo(pixelData); for (int i = 0; i &lt; pixelData.Length; i += frame.BytesPerPixel) { pixelData[i] = 0x00;//蓝色 pixelData[i + 1] = 0x00;//绿色 } this.colorImageBitMap.WritePixels(this.colorImageBitmapRect, pixelData,this.colorImageStride,0); } } } 以上的实验关闭了每个像素点的蓝色和绿色通道。for 循环遍历每个像素,使得 i 的起始 位置重视该像素的第一个字节。由于数据的格式是 Bgr32,即 RGB32 位(一个像素共占 4 个

字节,每个字节 8 位),所以第一个字节是蓝色通道,第二个是绿色,第三个是红色。循环 体类, 将第一个和第二个通道设置为 0.所以输出的代码中只用红色通道的信息。 这是最基本 的图像处理。 代码中对像素的操作和像素着色函数相识, 可以通过很复杂的算法来进行。 大家可以试试对 这些像素赋予一些其它的值然后再查看图像的显示结果。 这类操作通常很消耗计算资源。 像 素着色通常是 GPU 上的一些很基础的操作。下面有一些简单的算法用来对像素进行处理。 Inverted Color pixelData[i]=(byte)~pixelData[i]; pixelData[i+1]=(byte)~pixelData[i+1]; pixelData[i+2]=(byte)~pixelData[i+2]; Apocalyptic Zombie pixelData[i]= pixelData[i+1]; pixelData[i+1]= pixelData[i]; pixelData[i+2]=(byte)~pixelData[i+2]; Gray scale byte gray=Math.Max(pixelData[i],pixelData[i+1]) gray=Math.Max(gray,pixelData[i+2]); pixelData[i]=gray; pixelData[i+1]=gray; pixelData[i+2]=gray; Grainy black and white movie byte gray=Math.Min(pixelData[i],pixelData[i+1]); gray=Math.Min(gray,pixelData[i+2]); pixelData[i]=gray; pixelData[i+1]=gray; pixelData[i+2] =gray; Washed out color double gray=(pixelData[i]*0.11)+(pixelData[i+1]*0.59)+(pixelData[i+2]*0.3); double desaturation=0.75; pixelData[i]=(byte)(pixelData[i]+desaturation*(gray-pixelData[i])); pixelData[i+1]=(byte)(pixelData[i+1]+desaturation*(gray-pixelData[i+1])); pixelData[i+2]=(byte)(pixelData[i+2]+desatuation*(gray-pixelData[i+2])); High saturation If (pixelData[i]<0x33||pixelData[i]>0xE5) { pixelData[i]=0x00; } else { pixelData[i]=0Xff; } If (pixelData[i+1]<0x33||pixelData[i+1]>0xE5) { pixelData[i+1]=0x00; } else

{ pixelData[i+1]=0Xff; } If (pixelData[i+2]<0x33||pixelData[i+2]>0xE5) { pixelData[i+2]=0x00; } else { pixelData[i+1]=0Xff; } 一下是上面操作后的图像:

3. 截图 有时候, 可能需要从彩色摄像头中截取一幅图像, 例如可能要从摄像头中获取图像来设置 人物头像。为了实现这一功能,首先需要在界面上设置一个按钮,代码如下: <Window x:Class="KinectApplicationFoundation.MainWindow" xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation" xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" Title="ColorImageStreamFromKinect" Height="350" Width="525"> <Grid> <Image x:Name="ColorImageElement"></Image>

<StackPanel HorizontalAlignment="Left" VerticalAlignment="Top"> <Button Content="Take Picture" Click="TakePictureButton_Click" /> </StackPanel> </Grid> </Window> private void TakePictureButton_Click(object sender, RoutedEventArgs e) { String fileName = "snapshot.jpg"; if (File.Exists(fileName)) { File.Delete(fileName); } using (FileStream savedSnapshot=new FileStream(fileName,FileMode.CreateNew)) { BitmapSource image =(BitmapSource) ColorImageElement.Source; JpegBitmapEncoder jpgEncoder = new JpegBitmapEncoder(); jpgEncoder.QualityLevel = 70; jpgEncoder.Frames.Add(BitmapFrame.Create(image)); jpgEncoder.Save(savedSnapshot); savedSnapshot.Flush(); savedSnapshot.Close(); savedSnapshot.Dispose(); } } 为了演示,上面的代码中在当前目录创建了一个文件名。这是一种简单保存文件的方法。 我们使用 FileStream 打开一个文件。 JpegBitmapEncoder 对象将 UI 上的图像转换为一个标准 的 JPEG 文件,保存完后,需要调用对象的 flush 方法,然后关闭,最后释放对象。虽然这 三部不需要,因为我们使用了 using 语句,这里是为了演示,所以把这三步加上了。 4. ColorImageStream 对象图 到此为止,我们讨论了如何发现以及初始化 Kinect 传感器,从 Kinect 的影像摄像头获取 图片。 现在让我们来看看一些关键的类, 以及他们之间的关系。 下图展现了 ColorImageStream 的对象模型图。 ColorImageStream 是 KinectSensor 对象的一个属性,如同 KinectSensorde 其它流一样,色 彩数据流在使用之前需要调用 Enable 方法。ColorImageStream 有一个重载的 Enabled 方法, 默认的 Eanbled 方法没有参数,重载的方法有一个 ColorImageFormat 参数, 他是一个枚举类 型 , 可 以 使 用 这 个 参 数 指 定 图 像 格 式 。 下 表 列 出 了 枚 举 成 员 。 默 认 的 Enabled 将 ColorImageStream 设置为每秒 30 帧的 640*480 的 RGB 影像数据。 一旦调用 Enabled 方法后, 就可以通过对象的 Foramt 属性获取到图像的格式了。

ColorImageStream 有 5 个属性可以设置摄像头的视场。这些属性都以 Nominal 开头,当 Stream 被设置好后,这些值对应的分辨率就设置好了。一些应用程序可能需要基于摄像头 的光学属性比如视场角和焦距的长度来进行计算。 ColorImageStream 建议程序员使用这些属 性,以使得程序能够面对将来分辨率的变化。 ImageStream 是 ColorImageStream 的基类。 因此 ColorImageStream 集成了 4 个描述每一帧 每一个像素数据的属性。在之前的代码中,我们使用这些属性创建了一个 WriteableBitmap 对象。这些属性与 ColorImageFormat 的设置有关。ImageStream 中除了这些属性外还有一个 IsEnabled 属性和 Disable 方法。IsEnabled 属性是一个只读的。当 Stream 打开时返回 true, 当调用了 Disabled 方法后就返回 false 了。Disable 方法关闭 Stream 流,之后数据帧的产生 就会停止, ColorFrameReady 事件的触发也会停止。 当 ColorImageStream 设置为可用状态后, 就能产生 ColorImageFrame 对象。ColorImageFrame 对象很简单。他有一个 Format 方法,他 是父类的 ColorImageFormat 值。他只有一个 CopyPixelDataTo 方法,能够将图像的像素数据 拷贝到指定的 byte 数组中,只读的 PixelDataLength 属性定义了数组的大小 PixelDataLength 属性通过对象的宽度, 高度以及每像素多少位属性来获得的。 这些属性都继承自 ImageFrame 抽象类。 数 据 流 的 格 式 决 定 了 像 素 的 格 式 , 如 果 数 据 流 是 以 ColorImageFormat.RgbResolution640*480Fps30 格式初始化的,那么像素的格式就是 Bgr32, 它表示每一个像素占 32 位(4 个字节),第一个字节表示蓝色通道值,第二个表示绿色,第三 个表示红色。第四个待用。当像素的格式是 Bgra32 时,第四个字节表示像素的 alpha 或者 透明度值。如果一个图像的大小是 640*480 ,那么对于的字节数组有 122880 个字节 (width*height*BytesPerPixel=640*480*4).在处理影像时有时候也会用到 Stride 这一术语,他 表示影像中一行的像素所占的字节数,可以通过图像的宽度乘以每一个像素所占字节数得 到。 除了描述像素数据的属性外,ColorImageFrame 对象还有一些列描述本身的属性。Stream 会为每一帧编一个号, 这个号会随着时间顺序增长。 应用程序不要假的每一帧的编号都比前 一帧恰好大 1,因为可能出现跳帧现象。另外一个描述帧的属性是 Timestamp。他存储自 KinectSensor 开机(调用 Start 方法)以来经过的毫秒数。当每一次 KinectSensor 开始时都会复 位为 0。 5. 获取数据的方式:事件模式 VS “拉”模式 目前为止我们都是使用 KinectSensor 对象的事件来获取数据的。事件在 WPF 中应用很广 泛,在数据或者状态发生变化时,事件机制能够通知应用程序。对于大多数基于 Kinect 开 发的应用程序来说基于事件的数据获取方式已经足够; 但它不是唯一的能从数据流中获取数 据的模式。应用程序能够手动的从 Kinect 数据流中获取到新的帧数据。 “拉”数据的方式就是应用程序会在某一时间询问数据源是否有新数据,如果有,就加载。 每一个 Kinect 数据流都有一个称之为 OpenNextFrame 的方法。当调用 OpenNextFrame 的方 式时, 应用程序可以给定一个超时的值, 这个值就是应用程序愿意等待新数据返回的最长时 间,以毫秒记。方法试图在超时之前获取到新的数据帧。如果超时,方法将会返回一个 null 值。 当使用事件模型时,应用程序注册数据流的 frame-ready 事件,为其指定方法。每当事件 触发时,注册方法将会调用事件的属性来获取数据帧。例如,在使用彩色数据流时,方法调 用 ColorImageFrameReadyEventArgs 对 象 的 OpenColorImageFrame 方 法 来 获 取 ColorImageFrame 对象。程序应该测试获取的 ColorImageFrame 对象是否为空,因为有可能

在某些情况下,虽然事件触发了,但是没有产生数据帧。除此之外,事件模型不需要其他的 检查和异常处理。相比而言,OpenNextFrame 方法在 KinectSensor 没有运行、Stream 没有初 始化或者在使用事件获取帧数据的时候都有可能会产生 InvalidOperationException 异常。应 用程序可以自由选择何种数据获取模式,比如使用事件方式获取 ColorImageStream 产生的 数据,同时采用“拉”的方式从 SkeletonStream 流获取数据。但是不能对同一数据流使用这两 种 模 式 。 AllFrameReady 事 件 包 括 了 所 有 的 数 据 流 — 意 味 着 如 果 应 用 程 序 注 册 了 AllFrameReady 事 件 。 任 何 试 图 以 拉 的 方 式 获 取 流 中 的 数 据 都 会 产 生 InvalidOperationException 异常。 在展示如何以拉的模式从数据流中获取数据之前,理解使用模式获取数据的场景很有必 要。使用“拉”数据的方式获取数据的最主要原因是性能,只在需要的时候采取获取数据。他 的缺点是,实现起来比事件模式复杂。除了性能,应用程序的类型有时候也必须选择“拉” 数据的这种模式。SDK 也能用于 XNA,他不同与 WPF,它不是事件驱动的。当需要使用 XNA 开发游戏时,必须使用拉模式来获取数据。使用 SDK 也能创建没有用户界面的控制台 应用程序。设想开发一个使用 Kinect 作为眼睛的机器人应用程序,他通过源源不断的主动 从数据流中读取数据然后输入到机器人中进行处理, 在这个时候, 拉模型是比较好的获取数 据的方式。下面的代码展示了如何使用拉模式获取数据: private KinectSensor _Kinect; private WriteableBitmap _ColorImageBitmap; private Int32Rect _ColorImageBitmapRect; private int _ColorImageStride; private byte[] _ColorImagePixelData; public MainWindow() { InitializeComponent(); CompositionTarget.Rendering += CompositionTarget_Rendering; } private void CompositionTarget_Rendering(object sender, EventArgs e) { DiscoverKinectSensor(); PollColorImageStream(); } 代码声明部分和之前的一样。基于“拉”方式获取数据也需要发现和初始化 KinectSensor 对象。方法使用 WriteBitmap 来创建帧影像。最大的不同是,在构造函数中我们将 Rendering 事件绑定到 CompositionTarget 对象上。ComposationTarget 对象表示应用程序中可绘制的界 面。Rendering 事件会在每一个渲染周期上触发。我们需要使用循环来取新的数据帧。有两 种方式来创建循环。一种是使用线程,将在下一节中介绍。另一种方式是使用普通的循环语 句。使用 CompositionTarget 对象有一个缺点,就是 Rendering 事件中如果处理时间过长会导 致 UI 线程问题。因为时间处理在主 UI 线程中。所以不应在事件中做一些比较耗时的操作。 Redering 事件中的代码需要做四件事情。 必须发现一个连接的 KinectSnesor, 初始化传感器。 响应传感器状态的变化, 以及拉取新的数据并对数据进行处理。 我们将这四个任务分为两个 方法。下面的代码列出了方法的实现。和之前的代码差别不大: private void DiscoverKinectSensor() { if(this._Kinect != null && this._Kinect.Status != KinectStatus.Connected)

{ this._Kinect = null; } if(this._Kinect == null) { this._Kinect = KinectSensor.KinectSensors.FirstOrDefault(x KinectStatus.Connected); if(this._Kinect != null) { this._Kinect.ColorStream.Enable(); this._Kinect.Start(); ColorImageStream colorStream = this._Kinect.ColorStream; this._ColorImageBitmap = new WriteableBitmap(colorStream.FrameWidth, colorStream.FrameHeight, 96, 96, PixelFormats.Bgr32, null); this._ColorImageBitmapRect = new Int32Rect(0, 0, colorStream.FrameWidth, colorStream.FrameHeight); this._ColorImageStride = colorStream.FrameWidth * colorStream.FrameBytesPerPixel; this.ColorImageElement.Source = this._ColorImageBitmap; this._ColorImagePixelData = new byte[colorStream.FramePixelDataLength]; } } } 下面的代码列出了 PollColorImageStream 方法的实现。 代码首先判断是否有 KinectSensor 可用.然后调用 OpneNextFrame 方法获取新的彩色影像数据帧。代码获取新的数据后,然后 更新 WriteBitmap 对象。这些操作包在 using 语句中,因为调用 OpenNextFrame 对象可能会 抛出异常。在调用 OpenNextFrame 方法时,将超时时间设置为了 100 毫秒。合适的超时时 间设置能够使得程序在即使有一两帧数据跳过时仍能够保持流畅。 我们要尽可能的让程序每 秒产生 30 帧左右的数据。 private void PollColorImageStream() { if(this._Kinect == null) { //TODO: Display a message to plug-in a Kinect. } else { try {

=>

x.Status

==

using(ColorImageFrame frame = this._Kinect.ColorStream.OpenNextFrame(100)) { if(frame != null) { frame.CopyPixelDataTo(this._ColorImagePixelData); this._ColorImageBitmap.WritePixels(this._ColorImageBitmapRect, this._ColorImagePixelData, this._ColorImageStride, 0); } } } catch(Exception ex) { //TODO: Report an error message } } }

总体而言,采用拉模式获取数据的性能应该好于事件模式。上面的例子展示了使用拉方 式获取数据,但是它有另一个问题。使用 CompositionTarget 对象,应用程序运行在 WPF 的 UI 线程中。 任何长时间的数据处理或者在获取数据时超时 时间的设置不当都会使得程序变 慢甚至无法响应用户的行为,因为这些操作都执行在 UI 线程上。解决方法是创建一个新的 线程,然后在这个线程上执行数据获取和处理操作。 在.net 中使用 BackgroundWorker 类能 够简单的解决这个问题。代码如下: private void Worker_DoWork(object sender, DoWorkEventArgs e) { BackgroundWorker worker = sender as BackgroundWorker; if(worker != null) { while(!worker.CancellationPending) { DiscoverKinectSensor(); PollColorImageStream(); } } } 首先,在变量声明中加入了一个 BackgroundWorker 变量 _Worker。在构造函数中,实例 化了一个 BackgroundWorker 类,并注册了 DoWork 事件,启动了新的线程。当线程开始时 就会触发 DoWork 事件。 事件不断循环知道被取消。 在循环体中, 会调用 DiscoverKinectSensor 和 PollColorImageStream 方法。如果直接使用之前例子中的这两个方法,你会发现会出现 InvalidOperationException 异常,错误提示为 “The calling thread cannot access this object because a different thread owns it”。这是由于,拉数据在 background 线程中,但是更新 UI 元 素却在另外一个线程中。在 background 线程中更新 UI 界面,需要使用 Dispatch 对象。WPF

中每一个 UI 元素都有一个 Dispathch 对象。下面是两个方法的更新版本: private void DiscoverKinectSensor() { if(this._Kinect != null && this._Kinect.Status != KinectStatus.Connected) { this._Kinect = null; } if(this._Kinect == null) { this._Kinect = KinectSensor.KinectSensors .FirstOrDefault(x => x.Status == KinectStatus.Connected); if(this._Kinect != null) { this._Kinect.ColorStream.Enable(); this._Kinect.Start(); ColorImageStream colorStream = this._Kinect.ColorStream; this.ColorImageElement.Dispatcher.BeginInvoke(new Action(() => { this._ColorImageBitmap = new WriteableBitmap(colorStream.FrameWidth, colorStream.FrameHeight, 96, 96, PixelFormats.Bgr32, null); this._ColorImageBitmapRect = new Int32Rect(0, 0, colorStream.FrameWidth, colorStream.FrameHeight); this._ColorImageStride = colorStream.FrameWidth * colorStream.FrameBytesPerPixel; this._ColorImagePixelData = new byte[colorStream.FramePixelDataLength]; this.ColorImageElement.Source = this._ColorImageBitmap; })); } } } private void PollColorImageStream() { if(this._Kinect == null) { //TODO: Notify that there are no available sensors. } else {

try { using(ColorImageFrame frame = this._Kinect.ColorStream.OpenNextFrame(100)) { if(frame != null) { frame.CopyPixelDataTo(this._ColorImagePixelData); this.ColorImageElement.Dispatcher.BeginInvoke(new Action(() => { this._ColorImageBitmap.WritePixels(this._ColorImageBitmapRect, this._ColorImagePixelData, this._ColorImageStride, 0); })); } } } catch(Exception ex) { //TODO: Report an error message } } } 到此为止,我们展示了两种采用“拉”方式获取数据的例子,这两个例子都不够健壮。比 如说还需要对资源进行清理,比如他们都没有释放 KinectSensor 对象,在构建基于 Kinect 的实际项目中这些都是需要处理的问题。 “拉”模式获取数据跟事件模式相比有很多独特的好处,但它增加了代码量和程序的复杂 度。在大多数情况下,事件模式获取数据的方法已经足够,我们应该使用该模式而不是“拉” 模式。唯一不能使用事件模型获取数据的情况是在编写非 WPF 平台的应用程序的时候。比 如, 当编写 XNA 或者其他的采用拉模式架构的应用程序。 建议在编写基于 WPF 平台的 Kinect 应用程序时采用事件模式来获取数据。只有在极端注重性能的情况下才考虑使用“拉”的方 式。 6. 结语 本节介绍了采用 WriteableBitmap 改进程序的性能,并讨论了 ColorImageStream 中几个重 要对象的对象模型图并讨论了个对象之间的相关关系。最后讨论了在开发基于 Kinect 应用 程序时,获取 KinectSensor 数据的两种模式,并讨论了各自的优缺点和应用场合,这些对于 之后的 DepthImageSteam 和 SkeletonStream 也是适用的。 Kinect for Windows SDK 开发入门(四):景深数据处理 上 Kinect 传感器的最主要功能之一就是能够产生三维数据, 通过这些数据我们能够创建一些很 酷的应用。开发 Kinect 应用程序之前,最好能够了解 Kinect 的硬件结构。Kinect 红外传感 器能够探测人体以及非人体对象例如椅子或者咖啡杯。 有很多商业组织和实验室正在研究使 用景深数据来探测物体。 本文详细介绍了 Kinect 红外传感器,景深数据格式,景深图像的获取与展示,景深图像

的增强处理。 1. Kinect 传感器 和许多输入设备不一样,Kinect 能够产生三维数据,它有红外发射器和摄像头。和其他 Kinect SDK 如 OpenNI 或者 libfreenect 等 SDK 不同,微软的 Kinect SDK 没有提供获取原始 红外数据流的方法,相反,Kinect SDK 从红外摄像头获取的红外数据后,对其进行计算处 理, 然后产生景深影像数据。 景深影像数据从 DepthImageFrame 产生, 它由 DepthImageStream 对象提供。 DepthImageStream 的 使 用 和 ColorImageStream 的 使 用 类 似 。 DepthImageStream 和 ColorImageStream 都继承自 ImageStream。可以像从 ColorImageStream 获取数据生成图像那 样生成景深图像。 先看看将景深数据展现出来需要的步骤。 下面的步骤和前面显示彩色影像 数据相似: 1. 创建一个新的 WPF 对象。 2. 添加 Microsoft.Kinect.dll 对象引用。 3. 添加一个 Image 元素到 UI 上,将名称改为 DepthImage。 4. 添加必要的发现和释放 KinectSensor 对象的代码。可以参照前面的文章。 5. 修改初始化 KinectSensor 对象的代码如下: private void InitializeKinectSensor(KinectSensor kinectSensor) { if (kinectSensor != null) { DepthImageStream depthStream = kinectSensor.DepthStream; depthStream.Enable(); depthImageBitMap = new WriteableBitmap(depthStream.FrameWidth, depthStream.FrameHeight, 96,96,PixelFormats.Gray16, null); depthImageBitmapRect = new Int32Rect(0, 0, depthStream.FrameWidth, depthStream.FrameHeight); depthImageStride = depthStream.FrameWidth * depthStream.FrameBytesPerPixel; DepthImage.Source = depthImageBitMap; kinectSensor.DepthFrameReady += kinectSensor_DepthFrameReady; kinectSensor.Start(); } } 6. 修改 DepthFrameReady 事件,代码如下: void kinectSensor_DepthFrameReady(object sender, DepthImageFrameReadyEventArgs e) { using (DepthImageFrame depthFrame = e.OpenDepthImageFrame()) { if (depthFrame != null) { short[] depthPixelDate = new short[depthFrame.PixelDataLength]; depthFrame.CopyPixelDataTo(depthPixelDate);

depthImageBitMap.WritePixels(depthImageBitmapRect, depthImageStride, 0); } } }

depthPixelDate,

运行程序,将会看到如下结果,由于一手需要截图,一手需要站在 Kinect 前面所以姿势不 是很对,有点挫,不过人物的轮廓还是显示出来了,在景深数据中,离 Kinect 越近,颜色 越深,越远,颜色越淡。

2. Kinect 深度测量原理 和其他摄像机一样,近红外摄像机也有视场。Kinect 摄像机的视野是有限的,如下图所示:

如图,红外摄像机的视场是金字塔形状的。离摄像机远的物体比近的物体拥有更大的视 场横截面积。这意味着影像的高度和宽度,比如 640X480 和摄像机视场的物理位置并不一 一对应。但是每个像素的深度值是和视场中物体离摄像机的距离是对应的。深度帧数据中, 每个像素占 16 位,这样 BytesPerPixel 属性,即每一个像素占 2 个字节。每一个像素的深度 值只占用了 16 个位中的 13 个位。如下图:

获取每一个像素的距离很容易,但是要直接使用还需要做一些位操作。可能大家在实际 编程中很少情况会用到位运算。如上图所示,深度值存储在第 3 至 15 位中,要获取能够直 接使用的深度数据需要向右移位,将游戏者索引(Player Index)位移除。后面将会介绍游戏者 索引位的重要性。下面的代码简要描述了如何获取像素的深度值。代码中 pixelData 变量就 是从深度帧数据中获取的 short 数组。PixelIndex 基于待计算像素的位置就算出来的。SDK 在 DepthImageFrame 类中定义了一个常量 PlayerIndexBitmaskWidth, 它定义了要获取深度数 据值需要向右移动的位数。 在编写代码时应该使用这一常量而不是硬编码, 因为未来随着软 硬 件 水 平 的 提 高 , Kinect 可 能 会 增 加 能 够 同 时 识 别 人 数 的 个 数 , 从 而 改 变 PlayerIndexBitmaskWidth 常量的值。 Int32 pixelIndex = (Int32)(p.X + ((Int32)p.Y * frame.Width)); Int32 depth = this.depthPixelDate[pixelIndex] >> DepthImageFrame.PlayerIndexBitmaskWidth; 显示深度数据最简单的方式是将其打印出来。我们要将像素的深度值显示到界面上,当

鼠标点击时,显示鼠标点击的位置的像素的深度值。第一步是在主 UI 界面上添加一个 TextBlock: &lt;Window x:Class="KinectDepthImageDemo.MainWindow" xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation" xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" Title="KinectDepthImage" Height="600" Width="1280" WindowStartupLocation="CenterScreen"> <Grid> <StackPanel Orientation="Horizontal"> <TextBlock x:Name="PixelDepth" FontSize="48" HorizontalAlignment="Left" /> <Image x:Name="DepthImage" Width="640" Height="480" ></Image> </StackPanel> </Grid> </Window> 接着我们要处理鼠标点击事件。在添加该事件前,需要首先添加一个私有变量 lastDepthFrame 来保存每一次 DepthFrameReady 事件触发时获取到的 DepthFrame 值。因为 我们保存了对最后一个 DepthFrame 对象的引用,所以事件处理代码不会马上释放该对象。 然后,注册 DepthFrame 图像控件的 MouseLeftButtonUp 事件。当用户点击深度图像 时,DepthImage_MouseLeftButtonUp 事件就会触发,根据鼠标位置获取正确的像素。最后一 步将获取到的像素值的深度值显示到界面上,代码如下: void kinectSensor_DepthFrameReady(object sender, DepthImageFrameReadyEventArgs e) { if (lastDepthFrame!=null) { lastDepthFrame.Dispose(); lastDepthFrame = null; } lastDepthFrame = e.OpenDepthImageFrame(); if (lastDepthFrame != null) { depthPixelDate = new short[lastDepthFrame.PixelDataLength]; lastDepthFrame.CopyPixelDataTo(depthPixelDate); depthImageBitMap.WritePixels(depthImageBitmapRect, depthPixelDate, depthImageStride, 0); } } private void DepthImage_MouseLeftButtonUp(object sender, MouseButtonEventArgs e) { Point p = e.GetPosition(DepthImage); if (depthPixelDate != null &amp;& depthPixelDate.Length > 0) { Int32 pixelIndex = (Int32)(p.X + ((Int32)p.Y * this.lastDepthFrame.Width));

Int32 depth = this.depthPixelDate[pixelIndex] >> DepthImageFrame.PlayerIndexBitmaskWidth; Int32 depthInches = (Int32)(depth * 0.0393700787); Int32 depthFt = depthInches / 12; depthInches = depthInches % 12; PixelDepth.Text = String.Format("{0}mm~{1}'{2}", depth, depthFt, depthInches); } } 有一点值得注意的是,在 UI 界面中 Image 空间的属性中,宽度和高度是硬编码的。如果不 设置值,那么空间会随着父容器(From 窗体)的大小进行缩放,如果空间的长宽尺寸和深 度数据帧的尺寸不一致,当鼠标点击图片时,代码就会返回错误的数据,在某些情况下甚至 会抛出异常。像素数组中的数据是固定大小的,它是根据 DepthImageStream 的 Enable 方法 中的 DepthImageFormat 参数值来确定的。 如果不设置图像控件的大小, 那么他就会根据 Form 窗体的大小进行缩放,这样就需要进行额外的计算,将鼠标的在 Form 中的位置换算到深度 数据帧的维度上。这种缩放和空间转换操作很常见,在后面的文章中我们将会进行讨论,现 在为了简单,对图像控件的尺寸进行硬编码。 结果如下图,由于截屏时截不到鼠标符号,所以用红色点代表鼠标位置,下面最左边图 片中的红色点位于墙上,该点距离 Kinect 2.905 米,中间图的点在我的手上,可以看出手离 Kinect 距离为 1.221 米,实际距离和这个很相近,可见 Kinect 的景深数据还是很准确的。

上面最右边图中白色点的深度数据为-1mm。这表示 Kinect 不能够确定该像素的深度。在处 理上数据时,这个值通常是一个特殊值,可以忽略。-1 深度值可能是物体离 Kinect 传感器 太近了的缘故。

3. 深度影像增强 在进一步讨论之前,需要会深度值图像进行一些处理。在下面的最左边的图中,灰度级 都落在了黑色区域, 为了使图像具有更好的灰度级我们需要像之前对彩色数据流图像进行处 理那样,对深度值图像进行一些处理。 3.1 增强深度影像灰度级 增强深度值图像的最简单方法是按位翻转像素值。 图像的颜色是基于深度值的, 他们从 0 开始。在数字光谱中 0 表示黑色,65536(16 位灰阶)表示白色。这意味着下面最左边那幅图 中,大部分的值都落在了黑色部分。还有就是所有的不能确定深度值的数据都设置为了 0。 对位取反操作就会将这些值转换到白色的部分。 作为对比,现在在 UI 上再添加一个 Image 控件用来显示处理后的值。 <Window x:Class="KinectDepthImageDemo.MainWindow" xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation" xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" Title="KinectDepthImage" Height="600" Width="1280" WindowStartupLocation="CenterScreen"> <Grid> <StackPanel Orientation="Horizontal"> <Image x:Name="DepthImage" Width="640" Height="480" ></Image> <Image x:Name="EnhancedDepthImage" Width="640" Height="480" /> </StackPanel> </Grid> </Window> 下面的代码展示了如何将之前的深度位数据取反获取更好的深度影像数据。该方法在 kinectSensor_DepthFrameReady 事件中被调用。代码首先创建了一个新的 byte 数组,然后对 这个位数组进行取反操作。 注意代码中过滤掉了一些距离太近的点。 因为过近的点和过远的 点都不准确。所以过滤掉了大于 3.5 米小于 0 米的数据,将这些数据设置为白色。 private void CreateLighterShadesOfGray(DepthImageFrame depthFrame, short[] pixelData) { Int32 depth; Int32 loThreashold = 0; Int32 hiThreshold = 3500; short[] enhPixelData = new short[depthFrame.Width * depthFrame.Height]; for (int i = 0; i < pixelData.Length; i++) { depth = pixelData[i] >> DepthImageFrame.PlayerIndexBitmaskWidth; if (depth < loThreashold || depth > hiThreshold) { enhPixelData[i] = 0xFF; } else { enhPixelData[i] = (short)~pixelData[i];

} } EnhancedDepthImage.Source= BitmapSource.Create(depthFrame.Width, depthFrame.Height, 96, 96, PixelFormats.Gray16, null, enhPixelData, depthFrame.Width * depthFrame.BytesPerPixel); }

经过处理,图像(上面中间那幅图)的表现力提高了一些,但是如果能够将 16 位的灰度 级用 32 位彩色表示效果会更好。当 RGB 值一样时,就会呈现出灰色。灰度值的范围是 0~255,0 为黑色,255 为白色,之间的颜色为灰色。现在将灰色值以 RGB 模式展现出来。 代码如下: private void CreateBetterShadesOfGray(DepthImageFrame depthFrame, short[] pixelData) { Int32 depth; Int32 gray; Int32 loThreashold = 0;

Int32 bytePerPixel = 4; Int32 hiThreshold = 3500; byte[] enhPixelData = new byte[depthFrame.Width * depthFrame.Height*bytePerPixel]; for (int i = 0,j=0; i < pixelData.Length; i++,j+=bytePerPixel) { depth = pixelData[i] >> DepthImageFrame.PlayerIndexBitmaskWidth; if (depth < loThreashold || depth > hiThreshold) { gray = 0xFF; } else { gray = (255*depth/0xFFF); } enhPixelData[j] = (byte)gray; enhPixelData[j + 1] = (byte)gray; enhPixelData[j + 2] = (byte)gray; } EnhancedDepthImage.Source = BitmapSource.Create(depthFrame.Width, depthFrame.Height, 96, 96, PixelFormats.Bgr32, null, enhPixelData, depthFrame.Width * bytePerPixel); } 上面的代码中,将彩色影像的格式改为了 Bgr32 位,这意味每一个像素占用 32 位(4 个 字节) 。每一个 R,G,B 分别占 8 位,剩余 8 位留用。这种模式限制了 RGB 的取值为 0-255, 所以需要将深度值转换到这一个范围内。除此之外,我们还设置了最小最大的探测范围,这 个和之前的一样,任何不在范围内的都设置为白色。将深度值除以 4095(0XFFF,深度探 测的最大值) ,然后乘以 255,这样就可以将深度数据转换到 0 至 255 之间了。运行后效果 如上右图所示, 可以看出, 采用颜色模式显示灰度较之前采用灰度模式显示能够显示更多的 细节信息。 3.2 深度数据的彩色渲染 将深度数据值转化到 0-255 并用 RGB 模式进行显示可以起到增强图像的效果, 能够从图 像上直观的看出更多的深度细节信息。还有另外一种简单,效果也不错的方法,那就是将深 度数据值转换为色调和饱和度并用图像予以显示。下面的代码展示了这一实现: private void CreateColorDepthImage(DepthImageFrame depthFrame, short[] pixelData) { Int32 depth; Double hue; Int32 loThreshold = 1200; Int32 hiThreshold = 3500; Int32 bytesPerPixel = 4;

byte[] rgb = new byte[3]; byte[] enhPixelData = new byte[depthFrame.Width * depthFrame.Height * bytesPerPixel]; for (int i = 0, j = 0; i < pixelData.Length; i++, j += bytesPerPixel) { depth = pixelData[i] >> DepthImageFrame.PlayerIndexBitmaskWidth; if (depth < loThreshold || depth > hiThreshold) { enhPixelData[j] = 0x00; enhPixelData[j + 1] = 0x00; enhPixelData[j + 2] = 0x00; } else { hue = ((360 * depth / 0xFFF) + loThreshold); ConvertHslToRgb(hue, 100, 100, rgb); enhPixelData[j] = rgb[2]; //Blue enhPixelData[j + 1] = rgb[1]; //Green enhPixelData[j + 2] = rgb[0]; //Red } } EnhancedDepthImage.Source = BitmapSource.Create(depthFrame.Width, depthFrame.Height, 96, 96, PixelFormats.Bgr32, null, enhPixelData, depthFrame.Width * bytesPerPixel); } 以上代码中使用了 ConvertHslToRgb 这一函数,该函数的作用是进行两个颜色空间的转换, 就是将 H(Hue 色调)S(Saturation 饱和度)L(Light 亮度)颜色空间转换到 RGB 颜色空间的函数。 之前学过遥感图像处理,所以对这两个颜色空间比较熟悉。转化的代码如下: public void ConvertHslToRgb(Double hue, Double saturation, Double lightness, byte[] rgb) { Double red = 0.0; Double green = 0.0; Double blue = 0.0; hue = hue % 360.0; saturation = saturation / 100.0; lightness = lightness / 100.0; if (saturation == 0.0) { red = lightness; green = lightness;

blue = lightness; } else { Double huePrime = hue / 60.0; Int32 x = (Int32)huePrime; Double xPrime = huePrime - (Double)x; Double L0 = lightness * (1.0 - saturation); Double L1 = lightness * (1.0 - (saturation * xPrime)); Double L2 = lightness * (1.0 - (saturation * (1.0 - xPrime))); switch (x) { case 0: red = lightness; green = L2; blue = L0; break; case 1: red = L1; green = lightness; blue = L0; break; case 2: red = L0; green = lightness; blue = L2; break; case 3: red = L0; green = L1; blue = lightness; break; case 4: red = L2; green = L0; blue = lightness; break; case 5: red = lightness; green = L0; blue = L1; break; }

} rgb[0] = (byte)(255.0 * red); rgb[1] = (byte)(255.0 * green); rgb[2] = (byte)(255.0 * blue); } 运行程序,会得到如下右图结果(为了对比,下面左边第一幅图是原始数据,第二幅图 是使用 RGB 模式显示深度数据) 。最右边图中,离摄像头近的呈蓝色,然后由近至远颜色 从蓝色变为紫色,最远的呈红色。图中,我手上托着截图用的键盘,所以可以看到,床离摄 像头最近,呈蓝色,键盘比人体里摄像头更近,呈谈蓝色,人体各部分里摄像头的距离也不 一样,胸、腹、头部离摄像头更近。后面的墙离摄像头最远,呈橙色至红色。

运行上面的程序会发现很卡,我好不容易才截到这张图,这是因为在将 HUL 空间向颜色 空间转换需要对 640*480=307200 个像素逐个进行运算,并且运算中有小数,除法等操作。 该计算操作和 UI 线程位于同一线程内,会阻塞 UI 线程更新界面。更好的做法是将这一运 算操作放在 background 线程中。每一次当 KinectSensor 触发 frame-ready 事件时,代码顺序 存储彩色影像。 转换完成后, backgroud 线程使用 WPF 中的 Dispatcher 来更新 UI 线程中 Image 对象的数据源。上一篇文章中以及讲过这一问题,这种异步的操作在基于 Kinect 开发的应

用中很常见, 因为获取深度数据是一个很频繁的操作。 如果将获取数据以及对数据进行处理 放在主 UI 线程中就会使得程序变得很慢,甚至不能响应用户的操作,这降低了用户体验。 4. 结语 本文介绍了 Kinect 红外摄像头产生的深度影像数据流,KinectSensor 探测深度的原理, 如何获取像素点的深度值,深度数据的可视化以及一些简单的增强处理。 限于篇幅原因,下一篇文章将会介绍 Kinect 景深数据影像处理,以及在本文第 2 节中所 景深数据格式中没有讲到的游戏者索引位(Player Index),最后将会介绍 KinectSensor 红外传 感器如何结合游戏者索引位获取人物的空间范围, 包括人物的宽度, 高度等信息, 敬请期待。 Kinect for Windows SDK 开发入门(五):景深数据处理 下 1. 简单的景深影像处理 在上篇文章中,我们讨论了如何获取像素点的深度值以及如何根据深度值产生影像。在 之前的例子中,我们过滤掉了阈值之外的点。这就是一种简单的图像处理,叫阈值处理。使 用的阈值方法虽然有点粗糙, 但是有用。 更好的方法是利用机器学习来从每一帧影像数据中 计算出阈值。Kinect 深度值最大为 4096mm,0 值通常表示深度值不能确定,一般应该将 0 值过滤掉。微软建议在开发中使用 1220mm(4’)~3810mm(12.5’)范围内的值。在进行其他 深度图像处理之前,应该使用阈值方法过滤深度数据至 1220mm-3810mm 这一范围内。 使用统计方法来处理深度影像数据是一个很常用的方法。阈值可以基于深度数据的平均 值或者中值来确定。 统计方法可以帮助确定某一点是否是噪声、 阴影或者是其他比较有意义 的物体,比如说用户的手的一部分。有时候如果不考虑像素的视觉意义,可以对原始深度进 行数据挖掘。对景深数据处理的目的是进行形状或者物体的识别。通过这些信息,程序可以 确定人体相对于 Kinect 的位置及动作。 1.1 深度影像数据直方图 直方图是统计数据分布的一个很有效的工具。在这里我们关心的是一个景深影像图中深 度值的分布。直方图能够直观地反映给定数据集中数据的分布状况。从直方图中,我们能够 看出深度值出现的频率以及聚集分组。 通过这些信息, 我们能够确定阈值以及其他能够用来 对图像进行过滤的指标, 使得能够最大化的揭示深度影像图中的深度信息。 为了展示这一点, 接下来我们将会展示一副景深影像数据的直方图, 并通过直方图, 使用一些简单的技术来过 滤掉我们不想要的像素点。 首先创建一个新的项目。然后根据之前文章中讲的步骤发现和初始化 KinectSensor 对象 来进行深度影像数据处理,包括注册 DepthFrameReady 事件。在添加实现深度直方图之前, 将 UI 界面更改为如下: &lt;Window x:Class="KinectDepthHistogram.MainWindow" xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation" xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" Title="MainWindow" Height="800" Width="1200" WindowStartupLocation="CenterScreen"> <Grid> <StackPanel> <StackPanel Orientation="Horizontal"> <Image x:Name="DepthImage" Width="640" Height="480" /> <Image x:Name="FilteredDepthImage" Width="640" Height="480" /> </StackPanel>

<ScrollViewer Margin="0,15" HorizontalScrollBarVisibility="Auto" VerticalScrollBarVisibility="Auto"> <StackPanel x:Name="DepthHistogram" Orientation="Horizontal" Height="300" /> </ScrollViewer> </StackPanel> </Grid> </Window> 创建直方图的方法很简单,就是创建一系列的矩形元素,然后将它添加到名为 DepthHistogram 的 StackPanel 元素中,由于 DepthHistogram 对象的 Orientation 属性设置为 Horizontal,所以这些矩形会水平排列。大多数应用程序计算直方图只是用来进行中间过程处 理用,如果想要将直方图展现出来,则需要在绘图上面做些工作。下面的代码展现了如何绘 制直方图: private void KinectDevice_DepthFrameReady(object sender, DepthImageFrameReadyEventArgs e) { using (DepthImageFrame frame = e.OpenDepthImageFrame()) { if (frame != null) { frame.CopyPixelDataTo(this._DepthPixelData); CreateBetterShadesOfGray(frame, this._DepthPixelData); CreateDepthHistogram(frame, this._DepthPixelData); } } } private void CreateDepthHistogram(DepthImageFrame depthFrame, short[] pixelData) { int depth; int[] depths = new int[4096]; double chartBarWidth = Math.Max(3, DepthHistogram.ActualWidth / depths.Length); int maxValue = 0;

DepthHistogram.Children.Clear();

//计算并获取深度值.并统计每一个深度值出现的次数 for (int i = 0; i < pixelData.Length; i++) { depth = pixelData[i] >> DepthImageFrame.PlayerIndexBitmaskWidth; if (depth >= LoDepthThreshold &amp;& depth <= HiDepthThreshold) {

depths[depth]++; } }

//查找最大的深度值 for (int i = 0; i < depths.Length; i++) { maxValue = Math.Max(maxValue, depths[i]); }

//绘制直方图 for (int i = 0; i < depths.Length; i++) { if (depths[i] > 0) { Rectangle r = new Rectangle(); r.Fill = Brushes.Black; r.Width = chartBarWidth; r.Height = DepthHistogram.ActualHeight * (depths[i] / (double)maxValue); r.Margin = new Thickness(1, 0, 1, 0); r.VerticalAlignment = System.Windows.VerticalAlignment.Bottom; DepthHistogram.Children.Add(r); } } } 绘制直方图时,创建一个数组来存储所有可能的深度值数据,因此数组的大小为 4096。 第一步遍历深度图像,获取深度值,然后统计深度值出现的次数。因为设置了最高最低的距 离阈值,忽略了 0 值。下图显示了深度值影像的直方图,X 轴表示深度值,Y 轴表示深度值 在图像中出现的次数。

当站在 Kinect 前后晃动时,下面的直方图会不停的变化。图中后面最长的几个线条表示 墙壁,大约离摄像头 3 米左右,前面的几个小的线条是人体,大概离摄像头 2 米左右,下面 那副图中,我手上拿了一个靠垫,可以发现直方图与之前的直方图相比发生了一些变化。 这两幅图中,可以看到直方图都集中在两个地方,前面的一小撮和后面的那一大坨。所以根 据直方图可以看出,前面那个表示人体,后面那个代表房间的墙壁,在结合一些图像处理技 术,就大致可以把人体和背景区分开来了。 1.2 一些图像处理相关的知识 本文不打算详细讲解图像处理的相关知识。只是讨论如何获取原始的深度数据,以及理 解数据的用途。很多情况下,基于 Kinect 的应用程序不会对深度数据进行很多处理。如果 要处理数据,也应该使用一些类库诸如 OpenCV 库来处理这些数据。深度影像处理经常要 耗费大量计算资源,不应该使用诸如 C#这类的高级语言来进行影像处理。 Note: OpenCV(Open Source Computer Vision)库是是一个经常用来处理和计算影像数据的算 法类库。这个类库也包含点云库 (Point Cloud Library, PCL) 和 机器人操作系统 (Robot Operating System, ROS) ,这些都涉及到了大量的深度数据处理。有兴趣的可以研究一下

OpenCV 库。 应用程序处理深度数据目的是用来确定人体在 Kinect 视场中的位置。 虽然 Kinect SDK 中 的骨骼追踪在这方面功能更强大, 但是在某些情况下还是需要从深度数据中分析出人物所处 的位置。在下节中,我们将会分析人体在深度影像中的范围。在开始之前,有必要了解和研 究一下图像处理中常用的一些算法,有时候这些对特征提取非常有帮助。 图像处理 阈值处理(Thresholding) 图像分割 (Segmentation) 边缘/轮廓探测 (Edge/Contour Detection) 高斯滤波(Gaussian filter) Sobel、Prewitt、Kirsh 算子 Canny 算子 罗伯特 算子 哈夫变换 Blob 检测 拉普拉斯变换 Hession 算子 K 均值聚类 2. 深度数据和游戏者索引位 Kinect SDK 具有分析景深数据和探测人体或者游戏者轮廓的功能,它一次能够识别多达 6 个游戏者。 SDK 为每一个追踪到的游戏者编号作为索引。 游戏者索引存储在深度数据的前 3 个位中。如前一篇文章讨论的,景深数据每一个像素占 16 位,0-2 位存储游戏者索引值, 3-15 为存储深度值。7 (0000 0111)这个位掩码能够帮助我们从深度数据中获取到游戏者索引 值 。 幸 运 的 是 , SDK 为 游 戏 者 索 引 位 定 义 了 一 些 列 常 量 。 他 们 是 DepthImageFrame.PlayerIndexBitmaskWidth 和 DepthImageFrame.PlayerIndexBitmask。 前一个 值是 3,后一个是 7。开发者应该使用 SDK 定义的常量而不应该硬编码 3 或者 7。

游戏者索引位取值范围为 0~6, 值为 0 表示该像素不是游戏者。 但是初始化了景深数据流 并没有开启游戏者追踪。游戏者追踪需要依赖骨骼追踪技术。初始化 KinectSensor 对象和 DepthImageStream 对象时,需要同时初始化 SkeletonStream 对象。只有当 SkeletonStream 对 象初始化了后,景深数据中才会有游戏者索引信息。获取游戏者索引信息并不需要注册 SkeletonFrameReady 事件。 再创建一个工程来展示如何获取游戏者索引位信息。首先,创建一个新的项目,初始化 KinectSensor 对象,初始化 DepthImageStream 和 SkeletonStream 对象,并注册 KinectSensor 的 DepthFrameReady 事件。在 UI 界面 MainWindows.xaml 中添加两个 Image 控件分别取名 为 RamDepthImage 和 EnhDepthImage。添加 WirteableBitmap 对象,代码如下:

<Window x:Class="KinectDepthImagePlayerIndex.MainWindow" xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation" xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" Title="Kinect Player Index" Height="600" Width="1200"> <Grid> <StackPanel Orientation="Horizontal"> <Image x:Name="RawDepthImage" Margin="0,0,10,0" Width="640" Height="480"/> <Image x:Name="EnhDepthImage" Width="640" Height="480"/> </StackPanel> </Grid> </Window> 下面的代码将有游戏者索引位的数据显示为黑色,其他像元显示为白色。 private void KinectDevice_DepthFrameReady(object sender, DepthImageFrameReadyEventArgs e) { using (DepthImageFrame frame = e.OpenDepthImageFrame()) { if (frame != null) { frame.CopyPixelDataTo(this._RawDepthPixelData); this._RawDepthImage.WritePixels(this._RawDepthImageRect, this._RawDepthPixelData, this._RawDepthImageStride, 0); CreatePlayerDepthImage(frame, this._RawDepthPixelData); } } } private void CreatePlayerDepthImage(DepthImageFrame depthFrame, short[] pixelData) { int playerIndex; int depthBytePerPixel = 4; byte[] enhPixelData = new byte[depthFrame.Width * depthFrame.Height * depthBytePerPixel]; for (int i = 0, j = 0; i < pixelData.Length; i++, j += depthBytePerPixel) { playerIndex = pixelData[i] & DepthImageFrame.PlayerIndexBitmask; if (playerIndex == 0) { enhPixelData[j] = 0xFF; enhPixelData[j + 1] = 0xFF; enhPixelData[j + 2] = 0xFF; }

else { enhPixelData[j] = 0x00; enhPixelData[j + 1] = 0x00; enhPixelData[j + 2] = 0x00; } }

this._EnhDepthImage.WritePixels(this._EnhDepthImageRect, enhPixelData, this._EnhDepthImageStride, 0); } 运行后,效果如下图,还可以对上面的代码进行一些改进。例如,可以对游戏者所在的像素 进行灰度值拉伸, 能够绘制出游戏者深度值的直方图, 根据直方图可以看出每一灰度级对应 的频率。另一个改进是,可以对不同的游戏者给予不同的颜色显示,比如游戏者 1 用红色表 示 , 游 戏 者 2 用 蓝 色 表 示 等 等 。

要注意的是,不要对特定的游戏者索引位进行编码,因为他们是会变化的。实际的游戏 者索引位并不总是和 Kinect 前面的游戏者编号一致。 例如, Kinect 视野中只有一个游戏者, 但是返回的游戏者索引位值可能是 3 或者 4。有时候第一个游戏者的游戏者索引位可能不是 1,比如走进 Kinect 视野,返回的索引位是 1,走出去后再次走进,可能索引位变为其他值 了。所以开发 Kinect 应用程序的时候应该注意到这一点。 3. 对物体进行测量 像上篇文章中对深度值测量原理进行讨论的那样,像素点的 X,Y 位置和实际的宽度和 高度并不一致。但是运用几何知识,通过他们对物体进行测量是可能的。每一个摄像机都有 视场,焦距的长度和相机传感器的大小决定了视场角。Kinect 中相机的水平和垂直视场角分 别为 57° 和 43° 。既然我们知道了深度值,利用三角几何知识,就可以计算出物体的实际宽

度。示意图如下:

图中的公式在某些情况下可能不准确,Kinect 返回的数据也有这个问题。这个简化的公 式并没有考虑到游戏者的其他部分。尽管如此,公式依然能满足大部分的应用。这里只是简 单地介绍了如何将 Kinect 数据映射到真实环境中。如果想得到更好的精度,则需要研究 Kinect 摄像头的焦距和摄像头的尺寸。 在开始写代码前,先看看上图中的公式。摄像头的视场角是一个以人体深度位置为底的 一个等腰三角形。 人体的实际深度值是这个等腰三角形的高。 可以将这个等腰三角形以人所 在的位置分为两个直角三角形,这样就可以计算出底边的长度。一旦知道了底边的长度,我 们就可以将像素的宽度转换为现实中的宽度。 例如: 如果我们计算出等腰三角形底边的宽度 为 1500mm,游戏者所占有的总象元的宽度为 100,深度影像数据的总象元宽度为 320。那 么游戏者实际的宽度为 468.75mm((1500/320)*100)。公式中,我们需要知道游戏者的深度值 和游戏者占用的总的象元宽度。 我们可以将游戏者所在的象元的深度值取平均值作为游戏者 的深度值。之所以求平均值是因为人体不是平的,这能够简化计算。计算人物高度也是类似 的原理,只不过使用的垂直视场角和深度影像的高度。 知道了原理之后,就可以开始动手写代码实现了。先创建一个新的项目然后编写发现和 初始化 KinectSensor 的代码,将 DepthStream 和 SkeletonStream 均初始化,然后注册 KinectSnsor 的 DepthFrameReady 事件。主 UI 界面中的代码如下: <Window x:Class="KinectTakingMeasure.MainWindow" xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation" xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" Title="MainWindow" Height="800" Width="1200" WindowStartupLocation="CenterScreen"> <Grid> <StackPanel Orientation="Horizontal"> <Image x:Name="DepthImage" /> <ItemsControl x:Name="PlayerDepthData" Width="300" TextElement.FontSize="20"> <ItemsControl.ItemTemplate> <DataTemplate> <StackPanel Margin="0,15"> <StackPanel Orientation="Horizontal"> <TextBlock Text="PlayerId:" />

<TextBlock Text="{Binding Path=PlayerId}" /> </StackPanel> <StackPanel Orientation="Horizontal"> <TextBlock Text="Width:" /> <TextBlock Text="{Binding Path=RealWidth}" /> </StackPanel> <StackPanel Orientation="Horizontal"> <TextBlock Text="Height:" /> <TextBlock Text="{Binding Path=RealHeight}" /> </StackPanel> </StackPanel> </DataTemplate> </ItemsControl.ItemTemplate> </ItemsControl> </StackPanel> </Grid> </Window> 使用 ItemControl 的目的是用来显示结果。方法创建了一个对象来存放用户的深度数据以 及计算得到的实际宽度和高度值。程序创建了一个这样的对象数组。他是 ItemControl 的 ItemsSource 值。UI 定义了一个模板用来展示和游戏者深度值相关的数据,这个模板使用的 对象取名为 PlayerDepthData。 下面的名为 ClaculatePlayerSize 的方法将作为 DepthFrameReady 事件发生时执行的操作。 private void KinectDevice_DepthFrameReady(object sender, DepthImageFrameReadyEventArgs e) { using (DepthImageFrame frame = e.OpenDepthImageFrame()) { if (frame != null) { frame.CopyPixelDataTo(this._DepthPixelData); CreateBetterShadesOfGray(frame, this._DepthPixelData); CalculatePlayerSize(frame, this._DepthPixelData); } } } private void CalculatePlayerSize(DepthImageFrame depthFrame, short[] pixelData) { int depth; int playerIndex; int pixelIndex; int bytesPerPixel = depthFrame.BytesPerPixel; PlayerDepthData[] players = new PlayerDepthData[6]; for (int row = 0; row < depthFrame.Height; row++)

{ for (int col = 0; col < depthFrame.Width; col++) { pixelIndex = col + (row * depthFrame.Width); depth = pixelData[pixelIndex] >> DepthImageFrame.PlayerIndexBitmaskWidth; if (depth != 0) { playerIndex = DepthImageFrame.PlayerIndexBitmask) - 1;

(pixelData[pixelIndex]

&

if (playerIndex > -1) { if (players[playerIndex] == null) { players[playerIndex] = new PlayerDepthData(playerIndex + 1, depthFrame.Width, depthFrame.Height); } players[playerIndex].UpdateData(col, row, depth); } } } } PlayerDepthData.ItemsSource = players; } 粗体部分代码中使用了 PlayerDepthData 对象。 CalculatePlayerSize 方法遍历深度图像中的象 元, 然后提取游戏者索引位及其对应的深度值。 算法忽略了所有深度值为 0 的象元以及游戏 者之外的象元。对于游戏者的每一个象元,方法调用 PlayerDepthData 对象的 UpdateData 方 法。处理完所有象元之后,将游戏者数组复制给名为 PlayerDepthData 的 ItemControl 对象的 数据源。对游戏者宽度高度的计算封装在 PlayerDepthData 这一对象中。 PlayerDepthData 对象的代码如下: class PlayerDepthData { #region Member Variables private const double MillimetersPerInch = 0.0393700787; private static readonly double HorizontalTanA = Math.Tan(57.0 / 2.0 * Math.PI / 180); private static readonly double VerticalTanA = Math.Abs(Math.Tan(43.0 / 2.0 * Math.PI / 180)); private int _DepthSum; private int _DepthCount; private int _LoWidth;

private int _HiWidth; private int _LoHeight; private int _HiHeight; #endregion Member Variables

#region Constructor public PlayerDepthData(int playerId, double frameWidth, double frameHeight) { this.PlayerId = playerId; this.FrameWidth = frameWidth; this.FrameHeight = frameHeight; this._LoWidth = int.MaxValue; this._HiWidth = int.MinValue; this._LoHeight = int.MaxValue; this._HiHeight = int.MinValue; } #endregion Constructor

#region Methods public void UpdateData(int x, int y, int depth) { this._DepthCount++; this._DepthSum += depth; this._LoWidth = Math.Min(this._LoWidth, x); this._HiWidth = Math.Max(this._HiWidth, x); this._LoHeight = Math.Min(this._LoHeight, y); this._HiHeight = Math.Max(this._HiHeight, y); } #endregion Methods

#region Properties public int PlayerId { get; private set; } public double FrameWidth { get; private set; } public double FrameHeight { get; private set; }

public double Depth { get { return this._DepthSum / (double)this._DepthCount; }

}

public int PixelWidth { get { return this._HiWidth - this._LoWidth; } }

public int PixelHeight { get { return this._HiHeight - this._LoHeight; } }

public string RealWidth { get { double inches = this.RealWidthInches; return string.Format("{0:0.0}mm", inches * 25.4); } }

public string RealHeight { get { double inches = this.RealHeightInches; return string.Format("{0:0.0}mm", inches * 25.4); } }

public double RealWidthInches { get { double opposite = this.Depth * HorizontalTanA; return this.PixelWidth * 2 * opposite / this.FrameWidth * MillimetersPerInch; } }

public double RealHeightInches { get { double opposite = this.Depth * VerticalTanA; return this.PixelHeight * 2 * opposite / this.FrameHeight * MillimetersPerInch; } } #endregion Properties } 单独编写 PlayerDepthData 这个类的原因是封装计算逻辑。这个类有两个输入点和两个输 出点。构造函数以及 UpdateData 方法是两个输入点。ReadlWith 和 RealHeight 两个属性是两 个输出点。这两个属性是基于上图中的公式计算得出的。公式使用平均深度值,深度数据帧 的宽度和高度,和游戏者总共所占有的象元。平均深度值和所有的象元是通过参数传入到 UpdateData 方法中然后计算的出来的。真实的宽度和高度值是基于 UpdateData 方法提供的 数据计算出来的。下面是我做的 6 个动作的不同截图,右边可以看到测量值,手上拿了键盘 用来截图。

以上测量结果只是以 KinectSensor 能看到的部分来进行计算的。拿上图 1 来说。显示的 高度是 1563mm,宽度为 622mm。这里高度存在偏差,实际高度应该是 1665 左右,可能是 脚部和头部测量有误差。以上代码可以同时测量 6 个游戏者,但是由于只有我一个人,所以 做了 6 个不同的动作,截了 6 次图。还可以看到一点的是,如上面所讨论的,当只有一个游 戏者时,游戏者索引值不一定是从 1 开始,从上面 6 幅图可以看出,进出视野会导致游戏者 索引值发生变化,值是不确定的。 4.深度值图像和视频图像的叠加 在之前的例子中,我们将游戏者所属的象元用黑色显示出来,而其他的用白色显示,这 样就达到了提取人物的目的。 我们也可以将人物所属的象元用彩色表示, 而将其他部分用白 色表示。 但是, 有时候我们想用深度数据中游戏者所属的象元获取对应的彩色影像数据并叠 加到视频图像中。这在电视制作和电影制作中很常见,这种技术叫做绿屏抠像,就是演员或 者播音员站在绿色底板前,然后录完节目后,绿色背景抠出,换成其他场景,在一些科幻电 影中演员不可能在实景中表演时常采用的造景手法。 我们平常照证件照时, 背景通常是蓝色

或者红色,这样也是便于选取背景颜色方便抠图的缘故。在 Kinect 中我们也可以达到类似 的效果。Kinect SDK 使得这个很容易实现。 Note:这是现实增强的一个基本例子,现实增应用非常有趣而且能够获得非常好的用于体验。 许多艺术家使用 Kinect 来进行现实增强交互时展览。另外,这种技术也通常作为广告和营 销的工具。 前面的例子中,我们能够判断哪个像素是否有游戏者。但是这个只能对于景深数据使用。 不幸的是,景深数据影像的象元不能转换到彩色影像中去,即使两者使用相同的分辨率。因 为这两个摄像机位于 Kinect 上的不同位置,所以产生的影像不能够叠加到一起。就像人的 两只眼睛一样, 当你只睁开左眼看到的景象和只睁开右眼看到的景象是不一样的, 人脑将这 两只眼睛看到的景物融合成一幅合成的景象。 幸运的是, Kinect SDK 提供了一些方法来方便我们进行这些转换,这些方法位于 KinectSensor 对象中,他们是 MapDepthToColorImagePoint , MapDepthToSkeletonPoint , MapSkeletonPointToColor 和 MapSkeletonPointToDepth。在 DepthImageFrame 对象中这些方 法的名字有点不同(MapFromSkeletonPoint,MapToColorImagePoint 及 MapToSkeletonPoint), 但功能是相似的。在下面的例子中,我们使用 MapDepthToColorImagePoint 方法来将景深影 像中游戏者所属的象元转换到对应的彩色影像中去。 细心的读者可能会发现, 没有一个方法 能够将彩色影像中的象元转换到对应的景深影像中去。 创建一个新的工程,添加两个 Image 对象。第一个 Image 是背景图片。第二个 Image 是 前景图像。 在这个例子中, 为了使景深影像和彩色影像尽可能的接近, 我们采用轮询的方式。 每一个影像都有一个 Timestamp 对象,我们通过比较数据帧的这个值来确定他们是否足够 近。注册 KinectSensor 对象的 AllFrameReady 事件,并不能保证不同数据流产生的数据帧时 同步的。 这些帧不可能同时产生, 但是轮询模式能够使得不同数据源产生的帧能够尽可能的 够近。下面的代码展现了实现方式: private KinectSensor _KinectDevice; private WriteableBitmap _GreenScreenImage; private Int32Rect _GreenScreenImageRect; private int _GreenScreenImageStride; private short[] _DepthPixelData; private byte[] _ColorPixelData; private bool _DoUsePolling; private void CompositionTarget_Rendering(object sender, EventArgs e) { DiscoverKinect(); if (this.KinectDevice != null) { try { using (ColorImageFrame colorFrame this.KinectDevice.ColorStream.OpenNextFrame(100)) { using (DepthImageFrame depthFrame this.KinectDevice.DepthStream.OpenNextFrame(100)) {

=

=

RenderGreenScreen(this.KinectDevice, colorFrame, depthFrame); } } } catch (Exception) { //Do nothing, because the likely result is that the Kinect has been unplugged. } } } private void DiscoverKinect() { if (this._KinectDevice != null && this._KinectDevice.Status != KinectStatus.Connected) { UninitializeKinectSensor(this._KinectDevice); this._KinectDevice = null; }

if (this._KinectDevice == null) { this._KinectDevice = KinectSensor.KinectSensors.FirstOrDefault(x => x.Status == KinectStatus.Connected);

if (this._KinectDevice != null) { InitializeKinectSensor(this._KinectDevice); } } }

private void InitializeKinectSensor(KinectSensor sensor) { if (sensor != null) { sensor.DepthStream.Range = DepthRange.Default; sensor.SkeletonStream.Enable(); sensor.DepthStream.Enable(DepthImageFormat.Resolution640x480Fps30); sensor.ColorStream.Enable(ColorImageFormat.RgbResolution1280x960Fps12); DepthImageStream depthStream = sensor.DepthStream;

this._GreenScreenImage = new WriteableBitmap(depthStream.FrameWidth, depthStream.FrameHeight, 96, 96, PixelFormats.Bgra32, null); this._GreenScreenImageRect = new Int32Rect(0, 0, (int)Math.Ceiling(this._GreenScreenImage.Width), (int)Math.Ceiling(this._GreenScreenImage.Height)); this._GreenScreenImageStride = depthStream.FrameWidth * 4; this.GreenScreenImage.Source = this._GreenScreenImage; this._DepthPixelData = short[this._KinectDevice.DepthStream.FramePixelDataLength]; this._ColorPixelData = byte[this._KinectDevice.ColorStream.FramePixelDataLength]; if (!this._DoUsePolling) { sensor.AllFramesReady += KinectDevice_AllFramesReady; } sensor.Start(); } } private void UninitializeKinectSensor(KinectSensor sensor) { if (sensor != null) { sensor.Stop(); sensor.ColorStream.Disable(); sensor.DepthStream.Disable(); sensor.SkeletonStream.Disable(); sensor.AllFramesReady -= KinectDevice_AllFramesReady; } } 以上代码有三个地方加粗。第一地方引用了 RenderGreenScreen 方法。第二个和第三个地 方我们初始化了彩色和景深数据流。 当在两个图像之间转换时, 将彩色图形的分辨率设成景 深数据的两倍能够得到最好的转换效果。 RenderGreenScreen 方法中执行实际的转换操作。首先通过移除没有游戏者的象元创建一 个新的彩色影像。算法遍历景深数据的每一个象元,然后判断游戏者索引是否有有效值。然 后获取景深数据中游戏者所属象元对应的彩色图像上的象元, 将获取到的象元存放在象元数 组中。代码如下: private void RenderGreenScreen(KinectSensor kinectDevice, ColorImageFrame colorFrame, DepthImageFrame depthFrame) { if (kinectDevice != null && depthFrame != null && colorFrame != null) new new

{ int depthPixelIndex; int playerIndex; int colorPixelIndex; ColorImagePoint colorPoint; int colorStride = colorFrame.BytesPerPixel * colorFrame.Width; int bytesPerPixel = 4; byte[] playerImage = new byte[depthFrame.Height * this._GreenScreenImageStride]; int playerImageIndex = 0; depthFrame.CopyPixelDataTo(this._DepthPixelData); colorFrame.CopyPixelDataTo(this._ColorPixelData); for (int depthY = 0; depthY < depthFrame.Height; depthY++) { for (int depthX = 0; depthX < depthFrame.Width; depthX++, playerImageIndex += bytesPerPixel) { depthPixelIndex = depthX + (depthY * depthFrame.Width); playerIndex = this._DepthPixelData[depthPixelIndex] & DepthImageFrame.PlayerIndexBitmask; if (playerIndex != 0) { colorPoint = kinectDevice.MapDepthToColorImagePoint(depthFrame.Format, depthX, depthY, this._DepthPixelData[depthPixelIndex], colorFrame.Format); colorPixelIndex = (colorPoint.X * colorFrame.BytesPerPixel) + (colorPoint.Y * colorStride); playerImage[playerImageIndex] //Blue playerImage[playerImageIndex + 1] = this._ColorPixelData[colorPixelIndex + 1]; //Green playerImage[playerImageIndex + 2] = this._ColorPixelData[colorPixelIndex + 2]; //Red playerImage[playerImageIndex //Alpha } } } this._GreenScreenImage.WritePixels(this._GreenScreenImageRect, this._GreenScreenImageStride, 0); } playerImage, + 3] = 0xFF; = this._ColorPixelData[colorPixelIndex];

} PlayerImage 位数组存储了所有属于游戏者的彩色影像象元。从景深数据对应位置获取到 的彩色影像象元的大小和景深数据象元大小一致。与景深数据每一个象元占两个字节不同。 彩色影像数据每个象元占 4 个字节,蓝绿红以及 Alpha 值各占一个字节,在本例中 Alpha 值 很重要,它用来确定每个象元的透明度,游戏者所拥有的象元透明度设置为 255(0xFF)不透 明而其他物体则设置为 0,表示透明。 MapDepthToColorImagePoint 方法接受景深象元位置以及深度值,返回对应的对应彩色影 像中象元的位置。剩下的代码获取游戏者在彩色影像中的象元并将其存储到 PlayerImage 数 组中。当处理完所有的景深数据象元后,代码更新 Image 的数据源。运行程序后,需要站立 一段时间后人物才能够显示出来,如果移动太快,可能出来不了,因为景深数据和彩色数据 不能够对齐,可以看到任务轮廓有一些锯齿和噪声,但要处理这些问题还是有点麻烦的,它 需要对象元进行平滑。要想获得最好的效果,可以将多帧彩色影像合称为一帧。运行程序后 结果如下图,端了个键盘,人有点挫:

5.结语 本文首先介绍了关于景深数据的简单图像数据,包括景深数据的直方图显示以及一些图 像处理相关的算法,然后介绍了景深数据中的游戏者索引位,借助索引位,我们实现了人物 宽度和高度的计算, 最后借助景深数据结合彩色影像数据, 将景深影像和视频图像进行了叠 加。 至此,景深数据处理介绍完了,后面将会开始介绍 Kinect 的骨骼追踪技术,敬请期待。 Kinect for Windows SDK 开发入门(六):骨骼追踪基础 上 Kinect 产生的景深数据作用有限,要利用 Kinect 创建真正意义上交互,有趣和难忘的应用, 还需要除了深度数据之外的其他数据。 这就是骨骼追踪技术的初衷, 骨骼追踪技术通过处理 景深数据来建立人体各个关节的坐标,骨骼追踪能够确定人体的各个部分,如那部分是手, 头部,以及身体。骨骼追踪产生 X,Y,Z 数据来确定这些骨骼点。在上文中,我们讨论了景深

图像处理的一些技术。骨骼追踪系统采用的景深图像处理技术使用更复杂的算法如矩阵变 换,机器学习及其他方式来确定骨骼点的坐标。 本文首先用一个例子展示骨骼追踪系统涉及的主要对象,然后在此基础上详细讨论骨骼 追踪中所涉及的对象模型。 1. 获取骨骼数据 本节将会创建一个应用来将获取到的骨骼数据绘制到 UI 界面上来。在开始编码前,首 先来看看一些基本的对象以及如何从这些对象中如何获取骨骼数据。 在进行数据处理之前了 解数据的格式也很有必要。 这个例子很简单明了, 只需要骨骼数据对象然后将获取到的数据 绘制出来。 彩色影像数据,景深数据分别来自 ColorImageSteam 和 DepthImageStream,同样地,骨骼 数据来自 SkeletonStream。访问骨骼数据和访问彩色影像数据、景深数据一样,也有事件模 式和 “拉”模式两种方式。在本例中我们采用基于事件的方式,因为这种方式简单,代码量 少, 并且是一种很普通基本的方法。 KinectSensor 对象有一个名为 SkeletonFrameReady 事件。 当 SkeletonStream 中有新的骨骼数据产生时就会触发该事件。通过 AllFramesReady 事件也 可以获取骨骼数据。在下一节中,我们将会详细讨论骨骼追踪对象模型,现在我们只展示如 何从 SkeletonStream 流中获取骨骼数据。SkeletonStream 产生的每一帧数据都是一个骨骼对 象集合。 每一个骨骼对象包含有描述骨骼位置以及骨骼关节的数据。 每一个关节有一个唯一 标示符如头(head)、肩(shoulder)、肘(dlbow)等信息和 3D 向量数据。 现在来写代码。首先创建一个新的 wpf 工程文件,添加 Microsoft.Kinect.dll。添加基本查 找和初始化传感器的代码,这些代码参考之前的文章。在开始启动传感器之前,初始化 SkeletonStream 数据流,并注册 KinectSensor 对象的 SkeletonFrameReady 事件,这个例子没 有使用彩色摄像机和红外摄像机产生的数据,所以不需要初始化这些数据流。UI 界面采用 默认的,将 Grid 的名称改为 LayoutRoot,之后就再 Grid 里面绘制。代码如下: &lt;Window x:Class="KinectSkeletonTracking.MainWindow" xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation" xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" Title="MainWindow" Height="350" Width="525"> <Grid x:Name="LayoutRoot" Background="White"> </Grid> </Window> 后台逻辑代码如下: private KinectSensor kinectDevice; private readonly Brush[] skeletonBrushes;//绘图笔刷 private Skeleton[] frameSkeletons; public MainWindow() { InitializeComponent(); skeletonBrushes = new Brush[] { Brushes.Black, Brushes.Crimson, Brushes.Indigo, Brushes.DodgerBlue, Brushes.Purple, Brushes.Pink }; KinectSensor.KinectSensors.StatusChanged += KinectSensors_StatusChanged; this.KinectDevice = KinectSensor.KinectSensors.FirstOrDefault(x => x.Status ==

KinectStatus.Connected); } public KinectSensor KinectDevice { get { return this.kinectDevice; } set { if (this.kinectDevice != value) { //Uninitialize if (this.kinectDevice != null) { this.kinectDevice.Stop(); this.kinectDevice.SkeletonFrameReady KinectDevice_SkeletonFrameReady; this.kinectDevice.SkeletonStream.Disable(); this.frameSkeletons = null; } this.kinectDevice = value; //Initialize if (this.kinectDevice != null) { if (this.kinectDevice.Status == KinectStatus.Connected) { this.kinectDevice.SkeletonStream.Enable(); this.frameSkeletons = Skeleton[this.kinectDevice.SkeletonStream.FrameSkeletonArrayLength]; this.kinectDevice.SkeletonFrameReady KinectDevice_SkeletonFrameReady; this.kinectDevice.Start(); } } } } } private void KinectSensors_StatusChanged(object sender, StatusChangedEventArgs e) { switch (e.Status) {

-=

new +=

case KinectStatus.Initializing: case KinectStatus.Connected: case KinectStatus.NotPowered: case KinectStatus.NotReady: case KinectStatus.DeviceNotGenuine: this.KinectDevice = e.Sensor; break; case KinectStatus.Disconnected: //TODO: Give the user feedback to plug-in a Kinect device. this.KinectDevice = null; break; default: //TODO: Show an error state break; } } 以上代码中, 值得注意的是 frameSkeletons 数组以及该数组如何在流初始化时进行内存分 配的。Kinect 能够追踪到的骨骼数量是一个常量。这使得我们在整个应用程序中能够一次性 的为数组分配内存。 为了方便, Kinect SDK 在 SkeletonStream 对象中定义了一个能够追踪到 的骨骼个数常量 FrameSkeletonArrayLength,使用这个常量可以方便的对数组进行初始化。 代码中也定义了一个笔刷数组, 这些笔刷在绘制骨骼时对多个游戏者可以使用不同的颜色进 行绘制。也可以将笔刷数组中的颜色设置为自己喜欢的颜色。 下面的代码展示了 SkeletonFrameReady 事件的响应方法,每一次事件被激发时,通过调 用事件参数的 OpenSkeletonFrame 方法就能够获取当前的骨骼数据帧。剩余的代码遍历骨骼 数据帧的 Skeleton 数组 frameSkeletons, 在 UI 界面通过关节点将骨骼连接起来, 用一条直线 代表一根骨骼。UI 界面简单,将 Grid 元素作为根结点,并将其背景设置为白色。 private void KinectDevice_SkeletonFrameReady(object sender, SkeletonFrameReadyEventArgs e) { using (SkeletonFrame frame = e.OpenSkeletonFrame()) { if (frame != null) { Polyline figure; Brush userBrush; Skeleton skeleton; LayoutRoot.Children.Clear(); frame.CopySkeletonDataTo(this.frameSkeletons);

for (int i = 0; i < this.frameSkeletons.Length; i++) { skeleton = this.frameSkeletons[i];

if (skeleton.TrackingState == SkeletonTrackingState.Tracked) { userBrush = this.skeletonBrushes[i % this.skeletonBrushes.Length]; //绘制头和躯干 figure = CreateFigure(skeleton, userBrush, new[] { JointType.Head, JointType.ShoulderCenter, JointType.ShoulderLeft, JointType.Spine, JointType.ShoulderRight, JointType.ShoulderCenter, JointType.HipCenter }); LayoutRoot.Children.Add(figure); figure = CreateFigure(skeleton, userBrush, new[] { JointType.HipLeft, JointType.HipRight }); LayoutRoot.Children.Add(figure); //绘制作腿 figure = CreateFigure(skeleton, userBrush, new[] { JointType.HipCenter, JointType.HipLeft, JointType.KneeLeft, JointType.AnkleLeft, JointType.FootLeft }); LayoutRoot.Children.Add(figure); //绘制右腿 figure = CreateFigure(skeleton, userBrush, new[] { JointType.HipCenter, JointType.HipRight, JointType.KneeRight, JointType.AnkleRight, JointType.FootRight }); LayoutRoot.Children.Add(figure); //绘制左臂 figure = CreateFigure(skeleton, userBrush, new[] { JointType.ShoulderLeft, JointType.ElbowLeft, JointType.WristLeft, JointType.HandLeft }); LayoutRoot.Children.Add(figure); //绘制右臂 figure = CreateFigure(skeleton, userBrush, new[] { JointType.ShoulderRight, JointType.ElbowRight, JointType.WristRight, JointType.HandRight }); LayoutRoot.Children.Add(figure); } } } } } 循环遍历 frameSkeletons 对象, 每一次处理一个骨骼, 在处理之前需要判断是否是一个追 踪好的骨骼,可以使用 Skeleton 对象的 TrackingState 属性来判断,只有骨骼追踪引擎追踪

到的骨骼我们才进行绘制,忽略哪些不是游戏者的骨骼信息即过滤掉那些 TrackingState 不 等于 SkeletonTrackingState.Tracked 的骨骼数据。 Kinect 能够探测到 6 个游戏者, 但是同时只 能够追踪到 2 个游戏者的骨骼关节位置信息。在后面我们将会详细讨论 TrackingState 这一 属性。 处理骨骼数据相对简单,首先,我们根 Kinect 追踪到的游戏者的编号,选择一种颜色笔 刷。 然后利用这只笔刷绘制曲线。 CreateFigure 方法为每一根骨骼绘制一条直线。 GetJointPoint 方法在绘制骨骼曲线中很关键。 该方法以关节点的三维坐标作为参数, 然后调用 KinectSensor 对象的 MapSkeletonPointToDepth 方法将骨骼坐标转换到深度影像坐标上去。后面我们将会 讨论为什么需要这样转换以及如何定义坐标系统。 现在我们只需要知道的是, 骨骼坐标系和 深度坐标及彩色影像坐标系不一样,甚至和 UI 界面上的坐标系不一样。在开发 Kinect 应用 程序中,从一个坐标系转换到另外一个坐标系这样的操作非常常见,GetJointPoint 方法的目 的就是将骨骼关节点的三维坐标转换到 UI 绘图坐标系统,返回该骨骼关节点在 UI 上的位 置。下面的代码展示了 CreateFigure 和 GetJointPoint 这两个方法。 private Polyline CreateFigure(Skeleton skeleton, Brush brush, JointType[] joints) { Polyline figure = new Polyline(); figure.StrokeThickness = 8; figure.Stroke = brush; for (int i = 0; i < joints.Length; i++) { figure.Points.Add(GetJointPoint(skeleton.Joints[joints[i]])); } return figure; } private Point GetJointPoint(Joint joint) { DepthImagePoint point = this.KinectDevice.MapSkeletonPointToDepth(joint.Position, this.KinectDevice.DepthStream.Format); point.X *= (int)this.LayoutRoot.ActualWidth / KinectDevice.DepthStream.FrameWidth; point.Y *= (int)this.LayoutRoot.ActualHeight / KinectDevice.DepthStream.FrameHeight; return new Point(point.X, point.Y); } 值得注意的是,骨骼关节点的三维坐标中我们舍弃了 Z 值,只用了 X,Y 值。Kinect 好不 容易为我们提供了每一个节点的深度数据(Z 值)而我们却没有使用,这看起来显得很浪费。 其实不是这样的,我们使用了节点的 Z 值,只是没有直接使用,没有在 UI 界面上展现出来 而已。在坐标空间转换中是需要深度数据的。可以试试在 GetJointPoint 方法中,将 joint 的 Position 中的 Z 值改为 0,然后再调用 MapSkeletonPointToDepth 方法,你会发现返回的对象

中 x 和 y 值均为 0,可以试试,将图像以 Z 值进行等比缩放,可以发现图像的大小是和 Z 值 (深度)成反的。也就是说,深度值越小,图像越大,即人物离 Kinect 越近,骨骼数据越大。 运行程序,会得到如下骨骼图像,这个是手握键盘准备截图的姿势。一开始可能需要调 整一些 Form 窗体的大小。程序会为每一个游戏者以一种颜色绘制骨骼图像,可以试着在 Kinect 前面移动,可以看到骨骼图像的变化,也可以走进然后走出图像以观察颜色的变化。 仔细观察有时候可以看到绘图出现了一些奇怪的图案,在讨论完骨骼追踪相关的 API 之后, 就会明白这些现象出现的原因了。

2. 骨骼对象模型 Kinect SDK 中骨骼追踪有一些和其他对象不一样的对象结构和枚举。 在 SDK 中骨骼追踪 相关的内容几乎占据了三分之一的内容,可见 Kinect 中骨骼追踪技术的重要性。下图展示 了骨骼追踪系统中涉及到的一些主要的对象模型。有四个最主要的对象,他们是 SkeletonStream,SkeletonFrame,Skeleton 和 Joint。下面将详细介绍这四个对象。

2.1 SkeletonStream 对象 SkeletonStream 对 象 产 生 SkeletonFrame 。 从 SkeletonStream 获 取 骨 骼 帧 数 据 和 从 ColorStream 及 DepthStream 中获取数据类似。可以注册 SkeletonFrameReady 事件或者 AllFramesReady 事件通过事件模型来获取数据,或者是使用 OpenNextFrame 方法通过“拉” 模 型 来 获 取 数 据 。 不 能 对 同 一 个 SkeletonStream 同 时 使 用 这 两 种 模 式 。 如 果 注 册 了 SkeletonFrameReady 事 件 然 后 又 调 用 OpenNextFrame 方 法 将 会 返 回 一 个 InvalidOperationException 异常。 SkeletonStream 的启动和关闭

除非启动了 SkeletonStream 对象, 否则, 不会产生任何数据, 默认情况下, SkeletonStream 对象是关闭的。要使 SkeletonStream 产生数据,必须调用对象的 Enabled 方法。相反,调用 Disable 方法能够使 SkeletonStream 对象暂停产生数据。SkeletonStream 有一个 IsEnabled 方 法来描述当前 SkeletonStream 对象的状态。只有 SkeletonStream 对象启动了,KinectSensor 对 象 的 SkeletonFrameReady 事 件 才 能 被 激 活 。 如 果 要 使 用 “ 拉 ” 模 式 来 获 取 数 据 SkeletonStream 也 必 须 启 动 后 才 能 调 用 OpenNextFrame 方 法 。 否 则 也 会 抛 出 InvalidOperationException 异常。 一般地在应用程序的声明周期中,一旦启动了 SkeletonStream 对象,一般会保持启动状 态。但是在有些情况下,我们希望关闭 SkeletonStream 对象。比如在应用程序中使用多个 Kinect 传感器时。只有一个 Kinect 传感器能够产生骨骼数据,这也意味着,即使使用多个 Kinect 传感器,同时也只能追踪到两个游戏者的骨骼数据信息。在应用程序执行的过程中, 有可能会关闭某一个 Kinect 传感器的 SkeletonStream 对象而开启另一个 Kinect 传感器的 SkeletonStream 对象。 另一个有可能关闭骨骼数据产生的原因是出于性能方面的考虑,骨骼数据处理是很耗费 计算性能的操作。打开骨骼追踪是可以观察的到 CPU 的占用率明显增加。当不需要骨骼数 据时,关闭骨骼追踪很有必要。例如,在有些游戏场景中可能在展现一些动画效果或者播放 视频,在这个动画效果或者视频播放时,停止骨骼追踪可能可以使得游戏更加流畅。 当然关闭 SkeletonStream 也有一些副作用。当 SkeletonStream 的状态发生改变时,所有的 数据产生都会停止和从新开始。 SkeletonStream 的状态改变会使传感器重新初始化,将 TimeStamp 和 FrameNumber 重置为 0。在传感器重新初始化时也有几毫秒的延迟。 平滑化 在前面的例子中,会注意到,骨骼运动会呈现出跳跃式的变化。有几个原因会导致出现 这一问题,可能是应用程序的性能,游戏者的动作不够连贯,也有可能是 Kinect 硬件的性 能问题。 骨骼关节点的相对位置可能在帧与帧之间变动很大, 这回对应用程序产生一些负面 的影像。 除了会影像用户体验和不愉快意外, 也可能会导致用户的形象或者手的颤动抽搐而 使用户感到迷惑。 SkeletonStream 对象有一种方法能够解决这个问题。 他通过将骨骼关节点的坐标标准化来 减少帧与帧之间的关节点位置差异。当初始化 SkeletonStream 对象调用重载的 Enable 方法 时可以传入一个 TransformSmoothParameters 参数。 SkeletonStream 对象有两个与平滑有关只 读 属 性 : IsSmoothingEnabled 和 SmoothParameters 。 当 调 用 Enable 方 法 传 入 了 TransformSmoothParameters 是 IsSmoothingEnabled 返回 true 而当使用默认的不带参数的 Enable 方法初始化时,IsSmoothingEnabled 对象返回 false。SmoothParameters 属性用来存储 定义平滑参数。TransformSmoothParameters 这个结构定义了一些属性: 修正值(Correction)属性,接受一个从 0-1 的浮点型。值越小,修正越多。 抖动半径(JitterRadius)属性,设置修正的半径,如果关节点“抖动”超过了设置的这个半径, 将会被纠正到这个半径之内。该属性为浮点型,单位为米。 最大偏离半径(MaxDeviationRadius)属性,用来和抖动半径一起来设置抖动半径的最大边界。 任何超过这一半径的点都不会认为是抖动产生的, 而被认定为是一个新的点。 该属性为浮点 型,单位为米。 预测帧大小(Prediction)属性,返回用来进行平滑需要的骨骼帧的数目。 平滑值(Smoothing)属性, 设置处理骨骼数据帧时的平滑量, 接受一个 0-1 的浮点值, 值越大, 平滑的越多。0 表示不进行平滑。 对骨骼关节点进行平滑处理会产生性能开销。平滑处理的越多,性能消耗越大。设置平

滑参数没有经验可以遵循。 需要不断的测试和调试已达到最好的性能和效果。 在程序运行的 不同阶段,可能需要设置不同的平滑参数。 Note: SDK 使用霍尔特指数平滑(Holt Double Exponential Smoothing)来对减少关节点的抖动。 指数平滑数据处理与时间有关。 骨骼数据是时间序列数据, 因为骨骼引擎会以某一时间间隔 不断产生一帧一帧的骨骼数据。 平滑处理使用统计方法进行滑动平均, 这样能够减少时间序 列数据中的噪声和极值。类似的处理方法最开始被用于金融市场和经济数据的预测。 骨骼追踪对象选择 默认情况下,骨骼追踪引擎会对视野内的所有活动的游戏者进行追踪。但只会选择两个 可能的游戏者产生骨骼数据,大多数情况下,这个选择过程不确定。如果要自己选择追踪对 象 , 需 要 使 用 AppChoosesSkeletons 属 性 和 ChooseSkeletons 方 法 。 默 认 情 况 下 AppChoosesSkeleton 属性为 false, 骨骼追踪引擎追踪所有可能的最多两个游戏者。 要手动选 择追踪者,需要将 AppChoosesSkeleton 设置为 true,并调用 ChooseSkeletons 方法,传入 TrackingIDs 已表明需要追踪那个对象。 ChooseSkeletons 方法接受一个,两个或者 0 个 TrackingIDs。当 ChooseSkeletons 方法传入 0 个参数时,引擎停止追踪骨骼信息。有一些需 要注意的地方: 如 果 调 用 ChooseSkeletons 方 法 时 AppChoosesSkeletons 的 属 性 为 false , 就 会 引 发 InvalidOperationExcepthion 的异常。 如 果 在 SkeletonStream 开 启 前 , 经 AppChoosesSkeletons 设 置 为 true , 只 有 手 动 调 用 ChooseSkeleton 方法后才会开始骨骼追踪。 在 AppChoosesSkeletons 设置为 true 之前,骨骼引擎自动选择追踪的游戏者,并且继续保持 这些该游戏者的追踪, 直到用户手动指定需要追踪的游戏者。 如果自动选择追踪的游戏者离 开场景,骨骼引擎不会自动更换追踪者。 将 AppChoosesSkeletons 冲新设置为 false 后, 骨骼引擎会继续对之前手动设置的游戏者进行 追踪, 直到这些游戏者离开视野。 当游戏这离开视野时骨骼引擎才会选择其他的可能的游戏 者进行追踪。 2.2 SkeletonFrame SkeletonStream 产 生 SkeletonFrame 对 象 。 可 以 使 用 事 件 模 型 从 事 件 参 数 中 调 用 OpenSkeletonFrame 方法来获取 SkeletonFrame 对象,或者采用”拉”模型调用 SkeletonStream 的 OpenNextFrame 来获取 SkeletonFrame 对象。 SkeletonFrame 对象会存储骨骼数据一段时间。 同以通过调用 SkeletonFrame 对象的 CopySkeletonDataTo 方法将其保存的数据拷贝到骨骼对 象数组中。SkeletonFrame 对象有一个 SkeletonArrayLength 的属性,这个属性表示追踪到的 骨骼信息的个数。 时间标记字段 SkeletonFrame 的 FrameNumber 和 Timestamp 字段表示当前记录中的帧序列信息。 FrameNumber 是景深数据帧中的用来产生骨骼数据帧的帧编号。帧编号通常是不连续的, 但是之后的帧编号一定比之前的要大。 骨骼追踪引擎在追踪过程中可能会忽略某一帧深度数 据,这跟应用程序的性能和每秒产生的帧数有关。例如,在基于事件获取骨骼帧信息中,如 果事件中处理帧数据的时间过长就会导致这一帧数据还没有处理完就产生了新的数据, 那么 这些新的数据就有可能被忽略了。如果采用“拉”模型获取帧数据,那么取决于应用程序设置 的骨骼引擎产生数据的频率,即取决于深度影像数据产生骨骼数据的频率。 Timestap 字段记录字 Kinect 传感器初始化以来经过的累计毫秒时间。 不用担心 FrameNumber

或者 Timestamp 字段会超出上限。FrameNumber 是一个 32 位的整型,Timestamp 是 64 位整 型。如果应用程序以每秒 30 帧的速度产生数据,应用程序需要运行 2.25 年才会达到 FrameNumber 的限,此时 Timestamp 离上限还很远。另外在 Kinect 传感器每一次初始化时, 这两个字段都会初始化为 0。可以认为 FrameNumber 和 Timestamp 这两个值是唯一的。 这两个字段在分析处理帧序列数据时很重要,比如进行关节点值的平滑,手势识别操作等。 在多数情况下,我们通常会处理帧时间序列数据,这两个字段就显得很有用。目前 SDK 中 并没有包含手势识别引擎。在未来 SDK 中加入手势引擎之前,我们需要自己编写算法来对 帧时间序列进行处理来识别手势,这样就会大量依赖这两个字段。 帧描述信息 FloorClipPlane 字 段 是 一 个 有 四 个 元 素 的 元 组 Tuple<int,int,int,int>, 每 一 个 都 是 Ax+By+Cz+D=0 地面平面(floor plane)表达式里面的系数项。 元组中第一个元素表示 A, 即x 前面的系数,一次类推,最后一个表示常数项,通常为负数,是 Kinect 距离地面高度。在 可能的情况下 SDK 会利用图像处理技术来确定这些系数。但是有时候这些系数不肯能能够 确定下来,可能需要预估。当地面不能确定时 FloorClipPlane 中的所有元素均为 0. 2.3 Skeleton Skeleton 类定义了一系列字段来描述骨骼信息, 包括描述骨骼的位置以及骨骼中关节可能 的位置信息。骨骼数据可以通过调用 SkeletonFrame 对象的 CopySkeletonDataTo 方法获得 Skeleton 数组。CopySkeletonDataTo 方法有一些不可预料的行为,可能会影响内存使用和其 引用的骨骼数组对象。产生的每一个骨骼数组对象数组都是唯一的。以下面代码为例: Skeleton[] skeletonA = new Skeleton[frame.SkeletonArrayLength]; Skeleton[] skeletonB = new Skeleton[frame.SkeletonArrayLength]; frame.CopySkeletonDataTo(skeletonA); frame.CopySkeletonDataTo(skeletonB); Boolean resultA = skeletonA[0] == skeletonB[0];//false Boolean resultB = skeletonA[0].TrackingId == skeletonB[0].TrackingId;//true 上面的代码可以看出,使用 CopySkeletonDataTo 是深拷贝对象,会产生两个不同的 Skeleton 数组对象。 TrackingID 骨骼追踪引擎对于每一个追踪到的游戏者的骨骼信息都有一个唯一编号。 这个值是整型, 他会随着新的追踪到的游戏者的产生添加增长。 和之前帧序号一样, 这个值并不是连续增长 的,但是能保证的是后面追踪到的对象的编号要比之前的编号大。另外,这个编号的产生是 不确定的。如果骨骼追踪引擎失去了对游戏者的追踪,比如说游戏者离开了 Kinect 的视野, 那么这个对应的唯一编号就会过期。当 Kinect 追踪到了一个新的游戏者,他会为其分配一 个新的唯一编号, 编号值为 0 表示这个骨骼信息不是游戏者的, 他在集合中仅仅是一个占位 符。 应用程序使用 TrackingID 来指定需要骨骼追踪引擎追踪那个游戏者。 调用 SkeletonStream 对象的 ChooseSkeleton 能以初始化对指定游戏这的追踪。 TrackingState

该字段表示当前的骨骼数据的状态。 下表展示了 SkeletonTrackingState 枚举的可能值机器 含义:

Position Position 一个 SkeletonPoint 类型的字段,代表所有骨骼的中间点。身体的中间点和脊柱关 节的位置相当。 改字段提供了一个最快且最简单的所有视野范围内的游戏者位置的信息, 而 不管其是否在追踪状态中。在一些应用中,如果不用关心骨骼中具体的关节点的位置信息, 那么该字段对于确定游戏者的位置状态已经足够。该字段对于手动选择要追踪的游戏者 (SkeletonStream.ChooseSkeleton)也是一个参考。例如,应用程序可能需要追踪距离 Kinect 最近的且处于追踪状态的游戏者,那么该字段就可以用来过滤掉其他的游戏者。 ClippedEdges ClippedEdges 字段用来描述追踪者的身体哪部分位于 Kinect 的视野范围外。他大体上提 供了一个追踪这的位置信息。使用这一属性可以通过程序调整 Kinect 摄像头的俯仰角或者 提示游戏者让其返回到视野中来。该字段类型为 FrameEdges ,他是一个枚举并且有一个 FlagsAtrribute 自定义属性修饰。这意味着 ClippedEdges 字段可以一个或者多个 FrameEdges 值。下面列出了 FrameEdges 的所有可能的值。

当游戏者身体的某一部分超出 Kinect 视场范围时,就需要对骨骼追踪产生的数据进行某 些改进, 因为某些部位的数据可能追踪不到或者不准确。 最简单的解决办法就是提示游戏者 身体超出了 Kinect 的某一边界范围让游戏者回到视场中来。例如,有时候应用程序可能不 关心游戏者超出 Kinect 视场下边界的情况,但是如果超出了左边界或者右边界时就会对应 用产生影响,这是可以针对性的给游戏者一些提示。另一个解决办法是调整 Kinect 设备的 物理位置。Kinect 底座上面有一个小的马达能够调整 Kinect 的俯仰角度。俯仰角度可以通 过更改 KinectSensor 对象的 ElevationAnagle 属性来进行调整。如果应用程序对于游戏者脚 部动作比较关注,那么通过程序调整 Kinect 的俯仰角能够决绝脚部超出视场下界的情况。 ElevationAnagle 以度为单位。KinectSensor 的 MaxElevationAngle 和 MinElevationAngle 确定了可以调整角度的上下界。任何将 ElevationAngle 设置超出上下界的操作将会掏出 ArgumentOutOfRangeExcepthion 异常。 微软建议不要过于频繁重复的调整俯仰角以免损坏马 达。为了使得开发这少犯错误和保护马达,SDK 限制了每秒能调整的俯仰角的值。SDK 限 制了在连续 15 次调整之后要暂停 20 秒。 Joints 每一个骨骼对象都有一个 Joints 字段。该字段是一个 JointsCollection 类型,它存储了一些列 的 Joint 结构来描述骨骼中可追踪的关节点 ( 如 head,hands,elbow 等等 ) 。应用程序使用 JointsCollection 索引获取特定的关节点,并通过节点的 JointType 枚举来过滤指定的关节点。 即使 Kinect 视场中没有游戏者 Joints 对象也被填充。 2.4 Joint 骨骼追踪引擎能够跟踪和获取每个用户的近 20 个点或者关节点信息。追踪的数据以关节点 数据展现,它有三个属性。JointType 属性是一个枚举类型。下图描述了可追踪的所有关节 点。

每一个关节点都有类型为 SkeletonPoint 的 Position 属性,他通过 X,Y,Z 三个值来描述关 节点的控件位置。X,Y 值是相对于骨骼平面空间的位置,他和深度影像,彩色影像的空间坐 标系不一样。KinectSnesor 对象有一些列的坐标转换方法,可以将骨骼坐标点转换到对应的 深度数据影像中去。 最后每一个 Skeleton 对象还有一个 JointTrackingState 属性, 他描述了该 关节点的跟踪状态及方式,下面列出了所有的可能值。

3. 结语 本文首先通过一个例子展示骨骼追踪系统所涉及的主要对象, 并将骨骼数据在 UI 界面上 进行了绘制,在此基础上详细介绍了骨骼追踪对象模型中涉及到的主要对象,方法和属性。 SDK 中骨骼追踪占了大概三分之一的内容,所以熟悉这些对象对于开发基于 Kinect 应用程 序至关重要。 Kinect for Windows SDK 开发入门(七):骨骼追踪基础 下 1. Kinect 连线游戏 相信大家在小时候都做过一个数学题目, 就是在纸上将一些列数字(用一个圆点表示)从小 到大用线连起来。游戏逻辑很简单,只不过我们在这里要实现的是动动手将这些点连起来, 而不是用笔或者鼠标。 这个小游戏显然没有第一人称射击游戏那样复杂,但如果能够做成那样更好。我们要使 用骨骼追踪引擎来收集游戏者的关节数据,执行操作并渲染 UI 界面。这个小游戏展示了自 然用户界面 (Natural User Interface, NUI) 的理念, 这正是基于 Kinect 开发的常见交互界面, 就是手部跟踪。这个连线小游戏没有仅仅用到了 WPF 的绘图功能,没有好看的图片和动画 效果,这些以后可以逐步添加。 在开始写代码之前,需要明确定义我们的游戏目标。连线游戏是一个智力游戏,游戏者 需要将数字从小到大连起来。程序可以自定义游戏上面的数字和位置(合称一个关卡) 。每 一个关卡包括一些列的数字(以点表示)及其位置。我们要创建一个 DotPuzzle 类来管理这些 点对象的集合。可能一开始不需要这个类,仅仅需要一个集合就可以,但是为了以后方便添 加其他功能,使用类更好一点。这些点在程序中有两个地方需要用到,一个是最开始的时候 在界面上绘制关卡,其次是判断用户是否碰到了这些点。 当用户碰到点时,程序开始绘制,直线以碰到的点为起始点,直线的终点位用户碰到的 下一个点。然后下一个点又作为另一条直线的起点,依次类推。直到最后一个点和第一个点 连起来,这样关卡算是通过了,游戏结束。 游戏规则定义好了之后,我们就可以开始编码了,随着这个小游戏的开发进度,可能会 添加一些其他的新功能。一开始,建一个 WPF 工程,然后引用 Microsoft.Kinect.dll,和之前 的项目一样,添加发现和初始化 Kinect 传感器的代码。然后注册 KinectSensor 对象的 SkeletonFrameReady 事件。 1.1 游戏的用户界面 游戏界面代码如下, 有几个地方需要说明一下。 Polyline 对象用来表示点与点之间的连线。 当用户在点和点之间移动手时, 程序将点添加到 Polyline 对象中。 PuzzleBoardElement Canvas 对象用来作为 UI 界面上所有点的容器。Grid 对象下面的 Canvas 的顺序是有意这样排列的, 我们使用另外一个 GameBoardElement Canvas 对象来存储手势,以 Image 来表示,并且能够 保证这一层总是在点图层之上。 将每一类对象放在各自层中的另外一个好处是重新开始一 个 新 的 游 戏 变 得 很 容 易 , 只 需 要 将 PuzzleBoardElement 节 点 下 的 所 有 子 节 点 清 除 , CrayonElement 元素和其他的 UI 对象不会受到影响。 Viewbox 和 Grid 对象对于 UI 界面很重要。 如上一篇文章中讨论的, 骨骼节点数据是基于 骨骼空间的。这意味着我们要将骨骼向量转化到 UI 坐标系中来才能进行绘制。我们将 UI 控件硬编码,不允许它随着 UI 窗体的变化而浮动。 Grid 节点将 UI 空间大小定义为 1920*1200。通常这个是显示器的全屏尺寸,而且他和深度影像数据的长宽比是一致的。这 能够使得坐标转换更加清楚而且能够有更加流畅的手势移动体验。 &lt;Window x:Class="KinectDrawDotsGame.MainWindow"

xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation" xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" Title="MainWindow" Height="600" Width="800" Background="White"> <Viewbox> <Grid x:Name="LayoutRoot" Width="1920" Height="1200"> <Polyline x:Name="CrayonElement" Stroke="Black" StrokeThickness="3" /> <Canvas x:Name="PuzzleBoardElement" /> <Canvas x:Name="GameBoardElement"> <Image x:Name="HandCursorElement" Source="Images/hand.png" Width="75" Height="75" RenderTransformOrigin="0.5,0.5"> <Image.RenderTransform> <TransformGroup> <ScaleTransform x:Name="HandCursorScale" ScaleX="1" /> </TransformGroup> </Image.RenderTransform> </Image> </Canvas> </Grid> </Viewbox> </Window> 硬编码 UI 界面也能够简化开发过程, 能够使得从骨骼坐标向 UI 坐标的转化更加简单和 快速,只需要几行代码就能完成操作。况且,如果不应编码,相应主 UI 窗体大小的改变将 会增加额外的工作量。 通过将 Grid 嵌入 Viewbox 节点来让 WPF 来帮我们做缩放操作。 最后 一个 UI 元素是 Image 对象,他表示手的位置。在这个小游戏中,我们使用这么一个简单的 图标代表手。你可以选择其他的图片或者直接用一个 Ellipse 对象来代替。本游戏中图片使 用的是右手。在游戏中,用户可以选择使用左手或者右手,如果用户使用左手,我们将该图 片使用 ScaleTransform 变换,使得变得看起来像右手。 1.2 手部追踪 游戏者使用手进行交互,因此准确判断是那只手以及手的位置对于基于 Kinect 开发的应 用程序显得至关重要。手的位置及动作是手势识别的基础。追踪手的运动是从 Kinect 获取 数据的最重要用途。在这个应用中,我们将忽视其他关节点信息。 小时候,我们做这中连线时一般会用铅笔或者颜料笔,然后用手控制铅笔或则颜料笔进 行连线。我们的这个小游戏颠覆了这种方式,我们的交互非常自然,就是手。这样有比较好 的沉浸感,使得游戏更加有趣。当然,开发基于 Kinect 的应用程序这种交互显得自然显得 至关重要。幸运的是,我们只需要一点代码就能实现这一点。 在应用程序中可能有多个游戏者,我们设定,不论那只手离 Kinect 最近,我们使用距离 Kinect 最近的那个游戏者的那只手作为控制程序绘图的手。当然,在游戏中,任何时候用户 可以选择使用左手还是右手,这会使得用户操作起来比较舒服,SkeletonFrameReady 代码如 下: private void KinectDevice_SkeletonFrameReady(object sender, SkeletonFrameReadyEventArgs e) {

using (SkeletonFrame frame = e.OpenSkeletonFrame()) { if (frame != null) { frame.CopySkeletonDataTo(this.frameSkeletons); Skeleton skeleton = GetPrimarySkeleton(this.frameSkeletons); Skeleton[] dataSet2 = new Skeleton[this.frameSkeletons.Length]; frame.CopySkeletonDataTo(dataSet2); if (skeleton == null) { HandCursorElement.Visibility = Visibility.Collapsed; } else { Joint primaryHand = GetPrimaryHand(skeleton); TrackHand(primaryHand); TrackPuzzle(primaryHand.Position); } } } } private static Skeleton GetPrimarySkeleton(Skeleton[] skeletons) { Skeleton skeleton = null; if (skeletons != null) { //查找最近的游戏者 for (int i = 0; i < skeletons.Length; i++) { if (skeletons[i].TrackingState == SkeletonTrackingState.Tracked) { if (skeleton == null) { skeleton = skeletons[i]; } else { if (skeleton.Position.Z > skeletons[i].Position.Z) { skeleton = skeletons[i]; }

} } } } return skeleton; } 每一次事件执行时,我们查找第一个合适的游戏者。程序不会锁定某一个游戏者。如果 有两个游戏者,那么靠 Kinect 最近的那个会是活动的游戏者。这就是 GetPrimarySkeleton 的 功能。如果没有活动的游戏者,手势图标就隐藏。否则,我们使用活动游戏者离 Kinect 最 近的那只手作为控制。查找控制游戏手的代码如下: private static Joint GetPrimaryHand(Skeleton skeleton) { Joint primaryHand = new Joint(); if (skeleton != null) { primaryHand = skeleton.Joints[JointType.HandLeft]; Joint righHand = skeleton.Joints[JointType.HandRight]; if (righHand.TrackingState != JointTrackingState.NotTracked) { if (primaryHand.TrackingState == JointTrackingState.NotTracked) { primaryHand = righHand; } else { if (primaryHand.Position.Z > righHand.Position.Z) { primaryHand = righHand; } } } } return primaryHand; } 优先选择的是距离 Kinect 最近的那只手。但是,代码不单单是比较左右手的 Z 值来判断 选择 Z 值小的那只手,如前篇文章讨论的,Z 值为 0 表示该点的深度信息不能确定。所以, 我们在进行比较之前需要进行验证,检查每一个节点的 TrackingState 状态。左手是默认的 活动手,除非游戏者是左撇子。右手必须显示的追踪,或者被计算认为离 Kinect 更近。在 操作关节点数据时,一定要检查 TrackingState 的状态,否则会得到一些异常的位置信息, 这样会导致 UI 绘制错误或者是程序异常。 知道了哪只手是活动手后,下一步就是在界面上更新手势图标的位置了。如果手没有被 追踪,隐藏图标。在一些比较专业的应用中,隐藏手势图标可以做成一个动画效果,比如淡 入或者放大然后消失。 在这个小游戏中只是简单的将其状态设置为不可见。 在追踪手部操作 时,确保手势图标可见,并且设定在 UI 上的 X,Y 位置,然后根据是左手还是右手确定 UI

界面上要显示的手势图标,然后更新。计算并确定手在 UI 界面上的位置可能需要进一步检 验,这部分代码和上一篇文章中绘制骨骼信息类似。后面将会介绍空间坐标转换,现在只需 要了解的是, 获取的手势值是在骨骼控件坐标系中, 我们需要将手在骨骼控件坐标系统中的 位置转换到对于的 UI 坐标系统中去。 private void TrackHand(Joint hand) { if (hand.TrackingState == JointTrackingState.NotTracked) { HandCursorElement.Visibility = Visibility.Collapsed; } else { HandCursorElement.Visibility = Visibility.Visible; DepthImagePoint point = this.kinectDevice.MapSkeletonPointToDepth(hand.Position, this.kinectDevice.DepthStream.Format); point.X = (int)((point.X * LayoutRoot.ActualWidth / kinectDevice.DepthStream.FrameWidth) - (HandCursorElement.ActualWidth / 2.0)); point.Y = (int)((point.Y * LayoutRoot.ActualHeight / kinectDevice.DepthStream.FrameHeight) - (HandCursorElement.ActualHeight / 2.0)); Canvas.SetLeft(HandCursorElement, point.X); Canvas.SetTop(HandCursorElement, point.Y); if (hand.JointType == JointType.HandRight) { HandCursorScale.ScaleX = 1; } else { HandCursorScale.ScaleX = -1; } } } 编译运行程序,当移动手时,手势图标会跟着移动。 1.3 绘制游戏界面逻辑 为了显示绘制游戏的逻辑,我们创建一个新的类 DotPuzzle。这个类的最主要功能是保存 一些数字,数字在集合中的位置决定了在数据系列中的前后位置。这个类允许序列化,我们 能够从 xml 文件中读取关卡信息来建立新的关卡。 public class DotPuzzle { public List<Point> Dots { get; set; } public DotPuzzle() {

this.Dots = new List<Point>(); } } 定义好结构之后,就可以开始将这些点绘制在 UI 上了。首先创建一个 DotPuzzle 类的实 例,然后定义一些点,puzzleDotIndex 用来追踪用户解题的进度,我们将 puzzleDotIndex 设 置为-1 表示用户还没有开始整个游戏,代码如下: public MainWindow() { InitializeComponent(); puzzle = new DotPuzzle(); this.puzzle.Dots.Add(new Point(200, 300)); this.puzzle.Dots.Add(new Point(1600, 300)); this.puzzle.Dots.Add(new Point(1650, 400)); this.puzzle.Dots.Add(new Point(1600, 500)); this.puzzle.Dots.Add(new Point(1000, 500)); this.puzzle.Dots.Add(new Point(1000, 600)); this.puzzle.Dots.Add(new Point(1200, 700)); this.puzzle.Dots.Add(new Point(1150, 800)); this.puzzle.Dots.Add(new Point(750, 800)); this.puzzle.Dots.Add(new Point(700, 700)); this.puzzle.Dots.Add(new Point(900, 600)); this.puzzle.Dots.Add(new Point(900, 500)); this.puzzle.Dots.Add(new Point(200, 500)); this.puzzle.Dots.Add(new Point(150, 400)); this.puzzleDotIndex = -1; this.Loaded += (s, e) => { KinectSensor.KinectSensors.StatusChanged += KinectSensors_StatusChanged; this.KinectDevice = KinectSensor.KinectSensors.FirstOrDefault(x => x.Status == KinectStatus.Connected); DrawPuzzle(this.puzzle); }; } 最后一步是在 UI 界面上绘制点信息。我们创建了一个名为 DrawPuzzle 的方法,在主窗 体加载完成的时候触发改事件。DrawPuzzle 遍历集合中的每一个点,然后创建 UI 元素表示 这个点,然后将这个点添加到 PuzzleBoardElement 节点下面。另一种方法是使用 XAML 创 建 UI 界面,将 DotPuzzle 对象作为 ItemControl 的 ItemSource 属性,ItemsControl 对象的 ItemTemplate 对象能够定义每一个点的外观和位置。这种方式更加优雅,他允许定义界面的 风格及主体。在这个例子中,我们把精力集中在 Kinect 代码方面而不是 WPF 方面,尽量减 少代码量来实现功能。如果有兴趣的话,可以尝试改为 ItemControl 这种形式。DrawPuzzle 代码如下:

private void DrawPuzzle(DotPuzzle puzzle) { PuzzleBoardElement.Children.Clear(); if (puzzle != null) { for (int i = 0; i < puzzle.Dots.Count; i++) { Grid dotContainer = new Grid(); dotContainer.Width = 50; dotContainer.Height = 50; dotContainer.Children.Add(new Ellipse { Fill = Brushes.Gray }); TextBlock dotLabel = new TextBlock(); dotLabel.Text = (i + 1).ToString(); dotLabel.Foreground = Brushes.White; dotLabel.FontSize = 24; dotLabel.HorizontalAlignment = HorizontalAlignment.Center; dotLabel.VerticalAlignment = VerticalAlignment.Center; dotContainer.Children.Add(dotLabel); //在 UI 界面上绘制点 Canvas.SetTop(dotContainer, puzzle.Dots[i].Y - (dotContainer.Height / 2)); Canvas.SetLeft(dotContainer, puzzle.Dots[i].X - (dotContainer.Width / 2)); PuzzleBoardElement.Children.Add(dotContainer); } } } 1.4 游戏逻辑实现 到目前为止,我们的游戏已经有了用户界面和基本的数据。移动手,能够看到手势图标 会跟着移动。我们要将线画出来。当游戏者的手移动到点上时,开始绘制直线的起点,然后 知道手朋到下一个点时,将这点作为直线的终点,并开始另一条直线,并以该点作为起点。 TrackPuzzle 代码如下: private void TrackPuzzle(SkeletonPoint position) { if (this.puzzleDotIndex == this.puzzle.Dots.Count) { //游戏结束 } else { Point dot; if (this.puzzleDotIndex + 1 < this.puzzle.Dots.Count) {

dot = this.puzzle.Dots[this.puzzleDotIndex + 1]; } else { dot = this.puzzle.Dots[0]; } DepthImagePoint point = this.kinectDevice.MapSkeletonPointToDepth(position, kinectDevice.DepthStream.Format); point.X = (int)(point.X * LayoutRoot.ActualWidth / kinectDevice.DepthStream.FrameWidth); point.Y = (int)(point.Y * LayoutRoot.ActualHeight / kinectDevice.DepthStream.FrameHeight); Point handPoint = new Point(point.X, point.Y); Point dotDiff = new Point(dot.X - handPoint.X, dot.Y - handPoint.Y); double length = Math.Sqrt(dotDiff.X * dotDiff.X + dotDiff.Y * dotDiff.Y); int lastPoint = this.CrayonElement.Points.Count - 1; //手势离点足够近 if (length < 25) { if (lastPoint > 0) { //移去最后一个点 this.CrayonElement.Points.RemoveAt(lastPoint); } //设置直线的终点 this.CrayonElement.Points.Add(new Point(dot.X, dot.Y)); //设置新的直线的起点 this.CrayonElement.Points.Add(new Point(dot.X, dot.Y)); //转到下一个点 this.puzzleDotIndex++; if (this.puzzleDotIndex == this.puzzle.Dots.Count) { //通知游戏者游戏结束 } } else { if (lastPoint > 0) {

//移除最后一个点,更新界面 Point lineEndpoint = this.CrayonElement.Points[lastPoint]; this.CrayonElement.Points.RemoveAt(lastPoint); //将手势所在的点作为线的临时终点 lineEndpoint.X = handPoint.X; lineEndpoint.Y = handPoint.Y; this.CrayonElement.Points.Add(lineEndpoint); } } } } 代码的大部分逻辑是如何将直线绘制到 UI 上面,另一部分逻辑是实现游戏的规则逻辑, 比如点要按照从小到大的顺序连起来。程序计算当前鼠标手势点和下一个点之间的直线距 离, 如果距离小于 25 个像素宽度, 那么认为手势移动到了这个点上。 当然 25 可能有点绝对, 但是对于这个小游戏,这应该是一个合适的值。因为 Kinect 返回的关节点信息可能有点误 差而且用户的手可能会抖动, 所以有效点击范围应该要比实际的 UI 元素大。 这一点在 Kinect 或者其他触控设备上都是应该遵循的设计原则。 如果用户移动到了这个点击区域, 就可以认 为用户点击到了这个目标点。 最后将 TrackPuzzle 方法添加到 SkeletonFrameReady 中就可以开始玩这个小游戏了。运 行游戏,结果如下:

1.5 进一步可改进地方 在功能上,游戏已经完成了。游戏者可以开始游戏,移动手掌就可以玩游戏了。但是离 完美的程序还很远。还需要进一步改进和完善。最主要的是要增加移动的平滑性。游戏过程 中可以注意到手势有时候会跳跃。 第二个主要问题是需要重新恢复游戏初始化状态。 现在的 程序,当游戏者完成游戏后只有结束应用程序才能开始新的游戏。 一个解决方式是,在左上角放一个重置按钮,当用户手进入到这个按钮上时,应用程序 重置游戏,将 puzzleDotIndex 设置为 0,清除 CrayonElement 对象中的所有子对象。最好的 方式是,创建一个名为 ResetPuzzle 的新方法。 为了能够使得这个游戏有更好的体验,下面是可以进行改进的地方: 创建更多的游戏场景。当游戏加载是,可以从 XML 文件中读取一系列的数据,然后让随机 的产生场景。 列出一系列游戏场景,可以让用户选择想玩那一个,可以创建一个菜单,让用户选择。 一旦用户完成了当前游戏,自动出现下一个游戏场景。 添加一些额外的数据,比如游戏名称,背景图片,提示信息等。例如,如果游戏结束,换一 个背景图片,来个动画,或者音乐。 添加一些提示,比如可以提示用户下一个点时那一个。可以设置一个计时器,当用户找下一 个点超过了某一个时间后,认为用户遇到了困难,可以进行有好的提示,例如可以用文字或 者箭头表示下一个点的位置。如果用户找到了,则重置计时器。 如果用户离开游戏,应该重置游戏。比如用户可能需要接电话,喝茶或者其他的,可以设置 一个定时器,当 Kinect 探测不到游戏者时可以开始计时,如果用户离开的时间超过了某一 个限度,那么就重置游戏。 可以试着当用户找到一个下一个点时给一点有效的激励, 比如说放一段小的音乐, 或者给一 个提示音等等。 当用户完成游戏后,可以出现一个绘图板,可供用户选择颜色,然后可以在屏幕上绘图。 2. 各种坐标空间及变换

在之前的各种例子中,我们处理和操作了关节点数据的位置。在大多数情况下,原始的 坐标数据是不能直接使用的。 骨骼点数据和深度数据或者彩色影像数据的测量方法不同。 每 一种类的数据(深度数据,影像数据,骨骼数据)都是在特定的集合坐标或空间内定义的。深 度数据或者影像数据用像素来表示,X,Y 位置从左上角以 0 开始。深度数据的 Z 方位数据 以毫米为单位。与这些不同的是,骨骼空间是以米为单位来描述的,以深度传感器为中心, 其 X,Y 值为 0。骨骼坐空间坐标系是右手坐标系,X 正方向朝右,Y 周正方向朝上 X 轴数 据范围为-2.2~2.2,总共范围为 4.2 米,Y 周范围为-1.6~1.6 米,Z 轴范围为 0~4 米。下图描 述了 Skeleton 数据流的空间坐标系。

2.1 空间变换 Kinect 的应用程序就是用户和虚拟的空间进行交互。应用程序的交互越频繁。就越能增 加应用的参与度和娱乐性。在上面的例子中,用户移动手来进行连线。我们知道用户需要将 两个点连接起来, 我们也需要知道用户的手是否在某一个点上。 这种判断只有通过将骨骼数 据变换到 UI 空间上去才能确定。由于 SDK 中骨骼数据并没有以一种可以直接在 UI 上绘图 的方式提供,所以我们需要做一些变换。 将数据从骨骼数据空间转换到深度数据空间很容易。SDK 提供了一系列方法来帮助我们 进行这两个空间坐标系的转换。KinectSensor 对象有一个称之为 MapSkeletonPointToDepth 的 方 法 能 够 将 骨 骼 点 数 据 转 换 到 UI 空 间 中 去 。 SDK 中 也 提 供 了 一 个 相 反 的 MapDepthToSkeletonPoint 方法。MapSkeletonPointToDepth 方法接受一个 SkeletonPoint 点和 一个 DepthImageFormat 作为参数。骨骼点数据来自 Skeleton 对象或者 Joint 对象的 Position 属性。 方法的名字中有 Depth, 并不只是字面上的意思。 目标空间并不需要 Kinect 深度影像。 事实上,DepthStream 不必初始化,方法通过 DepthImageFormat 来确定如何变化。一旦骨骼 点数据被映射到深度空间中去了之后,他能够进行缩放到任意的纬度。 在之前绘制骨骼数据的例子中,GetJointPoint 方法把每一个关节点数据转换 LayoutRoot 元素所在的到 UI 空间中,因为只有在 UI 空间中我们才能进行绘图。在上面的连线小游戏 中,我们进行了两次这种转换。一个是在 TrackHand 方法中,在这个例子中,我们计算并将 其转换到 UI 空间中,调整其位置,时期能够保证在点的中间。另一个地方是在 TrackPuzzle 方法中,使用用户的手势来绘制直线。这里只是简单的将数据从骨骼数据空间转换到 UI 空

间。 2.2 骨骼数据镜面对称 细心地你可能会发现,骨骼数据是镜面对称的。在大多数情况下,应用是可行的,因为 人对应于显示屏就应该是镜面对称。 在上面的连线小游戏中, 人对于与屏幕也应该是镜面对 称,这样恰好模拟人的手势。但是在一些游戏中,角色代表实际的游戏者,可能角色是背对 着游戏者的,这就是所谓的第三人称视角。在有些游戏中,这种镜像了的骨骼数据可能不好 在 UI 上进行表现。一些应用或者游戏希望能够直面角色,不希望有这种镜像的效果。当游 戏者挥动左手时也希望角色能够挥动左手。如果不修改代码直接绘制的话,在镜面效果下, 角色会挥动右手,这显然不符合要求。 不幸的是 SDK 中并没有一个选项或者属性能够进行设置来使得骨骼追踪引擎能够直接产 生非镜像数据。所以需要我们自己去编码进行这种转换,幸运的是,在了解了骨骼数据结构 后进行转换比较简单。通过反转骨骼节点数据的 X 值就可以实现这个效果。要实现 X 值的 反转,只需要将 X 的值乘以 -1 即可。我们可以对之前的那个绘制骨骼数据的例子中 GetJointPoint 方法进行一些调整,代码如下: private Point GetJointPoint(Joint joint) { DepthImagePoint point = this.KinectDevice.MapSkeletonPointToDepth(joint.Position, this.KinectDevice.DepthStream.Format); point.X *= -1*(int) this.LayoutRoot.ActualWidth / KinectDevice.DepthStream.FrameWidth; point.Y *= (int) this.LayoutRoot.ActualHeight / KinectDevice.DepthStream.FrameHeight; return new Point(point.X, point.Y); } 修改之后运行程序就会看到,当游戏者抬起左臂时,UI 界面上的人物将会抬起右脚。 3. SkeletonViewer 自定义控件 开发 Kinect 应用程序进行交互时,在开发阶段,将骨骼关节点数据绘制到 UI 界面上是非 常有帮助的。 在调试程序时骨骼数据影像能够帮助我们看到和理解原始输入数据, 但是在发 布程序时,我们不需要这些信息。一种办法是每一处都复制一份将骨骼数据绘制到 UI 界面 上的代码,这显然不符合 DIY 原则,所以我们应当把这部分代码独立出来,做成一个自定 义控件。 本节我们的目标是, 将骨骼数据查看代码封装起来, 并使其在调试时为我们提供更多的实 时信息。我们使用自定义控件来实现这一功能点。首先,创建一个名为 SkeletonViewer 的自 定义控件。这个控件可以是任何一个 panel 对象的一个子节点。创建一个自定义控件,并将 其 XAML 替换成如下代码: <UserControl x:Class="KinectDrawDotsGame.SkeletonViewer" xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation" xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006" xmlns:d="http://schemas.microsoft.com/expression/blend/2008" mc:Ignorable="d" d:DesignHeight="300" d:DesignWidth="300">

<Grid x:Name="LayoutRoot"> <Grid x:Name="SkeletonsPanel"/> <Canvas x:Name="JointInfoPanel"/> </Grid> </UserControl> SkeletonsPanel 就是绘制骨骼节点的 panel。JointInfoPanel 是在调试时用来显示额外信息 的图层。下一步是需要将一个 KinectSnesor 对象传递到这个自定义控件中来。为此,我们创 建了一个 DependencyProperty,使得我们可以使用数据绑定。下面的代码展示了这一属性。 KinectDeviceChange 静态方法对于任何使用该用户控件的方法和功能非常重要。 该方法首先 取消之前注册到 KinectSensor 的 SkeletonFrameReady 事件上的方法。 如果不注销这些事件会 导致内存泄漏。一个比较好的方法是采用弱事件处理模式(weak event handler pattern),这里不 详细讨论。方法另一部分就是当 KinectDevice 属性部位空值时,注册 SkeletonFrameReady 事件。 protected const string KinectDevicePropertyName = "KinectDevice"; public static readonly DependencyProperty KinectDeviceProperty = DependencyProperty.Register(KinectDevicePropertyName, typeof(KinectSensor), typeof(SkeletonViewer), new PropertyMetadata(null, KinectDeviceChanged)); private static void KinectDeviceChanged(DependencyObject owner, DependencyPropertyChangedEventArgs e) { SkeletonViewer viewer = (SkeletonViewer)owner; if (e.OldValue != null) { ((KinectSensor)e.OldValue).SkeletonFrameReady viewer.KinectDevice_SkeletonFrameReady; viewer._FrameSkeletons = null; }

-=

if (e.NewValue != null) { viewer.KinectDevice = (KinectSensor)e.NewValue; viewer.KinectDevice.SkeletonFrameReady += viewer.KinectDevice_SkeletonFrameReady; viewer._FrameSkeletons = new Skeleton[viewer.KinectDevice.SkeletonStream.FrameSkeletonArrayLength]; } } public KinectSensor KinectDevice { get { return (KinectSensor)GetValue(KinectDeviceProperty); } set { SetValue(KinectDeviceProperty, value); } } 现在用户控件能够接受来世 KinectSensor 对象的新的骨骼数据了。我们可以开始绘制这

些骨骼数据。下面的代码展示了 SkeletonFrameReady 事件。大部分的代码和之前例子中的 代码是一样的。一开始,判断用户控件的 IsEnable 控件是否被设置为 true。这个属性可以使 得应用程序可以方便的控制是否绘制骨骼数据。对于每一个骨骼数据,会调用两个方法,一 个是 DrawSkeleton 方法, DrawSkeleton 方法中有两个其他方法 (CreateFigure 和 GetJointPoint) 方法。另外一个方法是 TrackJoint 方法,这个方法显示节点的额外信息。TrackJoint 方法在 关节点所在的位置绘制圆圈, 然后在圆圈上显示 X,Y,X 坐标信息。 X,Y 值是想对于用户控件 的高度和宽度,以像素为单位。Z 值是深度值。 private void KinectDevice_SkeletonFrameReady(object sender, SkeletonFrameReadyEventArgs e) { SkeletonsPanel.Children.Clear(); JointInfoPanel.Children.Clear(); using (SkeletonFrame frame = e.OpenSkeletonFrame()) { if (frame != null) { if (this.IsEnabled) { frame.CopySkeletonDataTo(this._FrameSkeletons); for (int i = 0; i < this._FrameSkeletons.Length; i++) { DrawSkeleton(this._FrameSkeletons[i], this._SkeletonBrushes[i]); TrackJoint(this._FrameSkeletons[i].Joints[JointType.HandLeft], this._SkeletonBrushes[i]); TrackJoint(this._FrameSkeletons[i].Joints[JointType.HandRight], this._SkeletonBrushes[i]); } } } } } private void TrackJoint(Joint joint, Brush brush) { if (joint.TrackingState != JointTrackingState.NotTracked) { Canvas container = new Canvas(); Point jointPoint = GetJointPoint(joint); double z = joint.Position.Z ; Ellipse element = new Ellipse(); element.Height = 15; element.Width = 15; element.Fill = brush;

Canvas.SetLeft(element, 0 - (element.Width / 2)); Canvas.SetTop(element, 0 - (element.Height / 2)); container.Children.Add(element); TextBlock positionText = new TextBlock(); positionText.Text = string.Format("<{0:0.00}, {1:0.00}, {2:0.00}>", jointPoint.X, jointPoint.Y, z); positionText.Foreground = brush; positionText.FontSize = 24; positionText.Measure(new Size(double.PositiveInfinity, double.PositiveInfinity)); Canvas.SetLeft(positionText, 35); Canvas.SetTop(positionText, 15); container.Children.Add(positionText); Canvas.SetLeft(container, jointPoint.X); Canvas.SetTop(container, jointPoint.Y); JointInfoPanel.Children.Add(container); } } 将这个自定义控件加到应用中很简单。由于是自定义控件,自需要在应用程序的 XAML 文件中声明自定义控件,然后在程序中给 SkeletonViewer 的 KinectDevice 赋值,主界面和后 台逻辑代码更改部分如下加粗所示: <Window x:Class="KinectDrawDotsGame.MainWindow" xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation" xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" xmlns:c="clr-namespace:KinectDrawDotsGame" Title="MainWindow" Height="600" Width="800" Background="White"> <Viewbox> <Grid x:Name="LayoutRoot" Width="1920" Height="1200"> <c:SkeletonViewer x:Name="SkeletonViewerElement"/> <Polyline x:Name="CrayonElement" Stroke="Black" StrokeThickness="3" /> <Canvas x:Name="PuzzleBoardElement" /> <Canvas x:Name="GameBoardElement"> <Image x:Name="HandCursorElement" Source="Images/hand.png" Width="75" Height="75" RenderTransformOrigin="0.5,0.5"> <Image.RenderTransform> <TransformGroup> <ScaleTransform x:Name="HandCursorScale" ScaleX="1" /> </TransformGroup> </Image.RenderTransform> </Image> </Canvas>

</Grid> </Viewbox> </Window> public KinectSensor KinectDevice { get { return this.kinectDevice; } set { if (this.kinectDevice != value) { //Uninitialize if (this.kinectDevice != null) { this.kinectDevice.Stop(); this.kinectDevice.SkeletonFrameReady KinectDevice_SkeletonFrameReady; this.kinectDevice.SkeletonStream.Disable(); SkeletonViewerElement.KinectDevice = null; this.frameSkeletons = null; } this.kinectDevice = value; //Initialize if (this.kinectDevice != null) { if (this.kinectDevice.Status == KinectStatus.Connected) { this.kinectDevice.SkeletonStream.Enable(); this.frameSkeletons = Skeleton[this.kinectDevice.SkeletonStream.FrameSkeletonArrayLength]; SkeletonViewerElement.KinectDevice = this.KinectDevice; this.kinectDevice.Start(); this.KinectDevice.SkeletonFrameReady KinectDevice_SkeletonFrameReady; } } } } } 添加后,运行之前的程序,就可以看到如下界面:

-=

new

+=

4. 结语 本文通过介绍一个简单的 Kinect 连线游戏的开发来详细讨论如何骨骼追踪引擎来建立一 个完整的 Kinect 应用,然后简要介绍了各个坐标控件以及转换,最后建立了一个显示骨骼

信息的自定义控件, 并演示了如何将自定义控件引入到应用程序中。 下一篇文章将会结合另 外一个小游戏来介绍 WPF 的相关知识以及骨骼追踪方面进一步值得注意和改进的地方。 Kinect for Windows SDK 开发入门(八):骨骼追踪进阶 上 Kinect 传感器核心只是发射红外线,并探测红外光反射,从而可以计算出视场范围内每一个 像素的深度值。 从深度数据中最先提取出来的是物体主体和形状, 以及每一个像素点的游戏 者索引信息。 然后用这些形状信息来匹配人体的各个部分, 最后计算匹配出来的各个关节在 人体中的位置。这就是我们之前介绍过的骨骼追踪。 红外影像和深度数据对于 Kinect 系统来说很重要,它是 Kinect 的核心,在 Kinect 系统中其 重要性仅次于骨骼追踪。事实上,这些数据相当于一个输入终端。随着 Kinect 或者其他深 度摄像机的流行和普及。 开发者可以不用关注原始的深度影像数据, 他们变得不重要或者只 是作为获取其他数据的一个基础数据而已。我们现在就处在这个阶段,Kinect SDK 并没有 提供给开发者访问原始红外影像数据流的接口,但是其它第三方的 SDK 可以这么做。可能 大多数开发者不会使用原始的深度数据,用到的只是 Kinect 处理好了的骨骼数据。但是, 一旦姿势和手势识别整合到 Kinect SDK 并成为其一部分时,可能开发者甚至不用接触到骨 骼数据了。 希望能够早日实现这种集成,因为它代表这 Kinect 作为一种技术的走向成熟。本篇文章 和下篇文章仍将讨论骨骼追踪,但是采用不同的方法来处理骨骼数据。我们将 Kinect 作为 一个如同鼠标,键盘或者触摸屏那样的一个最基本的输入设备。微软当初推出 Kinect for Xbox 的口号是“你就是控制器”,从技术方面讲,就是“你就是输入设备”。通过骨骼数据, 应用程序可以做鼠标或者触摸屏可以做的事情, 所不同的是深度影像数据使得用户和应用程 序可以实现以前从没有过的交互方法。下面来看看 Kinect 控制并与用户界面进行交互的机 制吧。 1. 用户交互 运行在电脑上的应用程序需要输入信息。传统的信息来自于鼠标或者键盘等这些输入设 备。用户直接与这些硬件设备进行交互,然后硬件设备响应用户的操作,将这些操作转换成 数据传输到计算机中。计算机接收这些输入设备的信息然后将结果以可视化的形式展现出 来。大多数计算机的图像用户界面上会有一个光标(Cursor),他通常代表鼠标所在的位置, 因为鼠标是最开始有个滚轮设备。但是现在,如果将这个光标指代鼠标光标的话,可能不太 准确, 因为现在一些触摸板或手写设备也能像鼠标那样控制光标。 当用户移动鼠标或者在触 摸板上移动手指时,光标也能响应这种变化。当用户将光标移动到一个按钮上时,通常按钮 的外观会发生变化,提示用户光标正位于按钮上。当用户点击按钮时,按钮则为显示另一种 外观。当用户松开鼠标上的按键,按钮就会出现另外一种外观。显然,简单的点击事件会涉 及到按钮的不同状态。 开发者可能对这些交互界面和操作习以为常, 因为诸如 WPF 之类的用户交互平台使得程 序与用户进行交互变得非常简单。当开发网页程序时,浏览器响应用户的交互,开发者只需 要根据用户鼠标的悬停状态来设置样式即可进行交互。但是 Kinect 不同,他作为一个输入 设备,并没有整合到 WPF 中去,因此,作为一个开发者。对操作系统和 WPF 所不能直接 响应的那部分工作需要我们来完成。 在底层,鼠标,触摸板或者手写设备都是提供一些 X,Y 坐标,操作系统将这些 X,Y 坐 标从其在的空间坐标系统转换到计算机屏幕上, 这一点和上篇文章讨论的空间变换类似。 操 作系统的职责是响应这些标准输入设备输入的数据, 然后将其转换到图形用户界面或者应用 程序中去。操作系统的图形用户界面显示光标位置,并响应用户的输入。在有些时候,这个

过程没有那么简单,需要我们了解 GUI 平台。以 WPF 应用程序为例,它并没有对 Kinect 提供像鼠标和键盘那样的原生的支持。这个工作就落到开发者身上了,我们需要从 Kinect 中获取数据,然后利用这些数据与按钮,下拉框或者其他控件进行交互。根据应用程序或者 用户界面的复杂度的不同,这种工作可能需要我们了解很多有关 WPF 的知识。 1.1 WPF 应用程序中输入系统介绍 当开发一个 WPF 应用程序时,开发者并不需要特别关注用户输入机制。WPF 会为我们 处理这些机制使得我们可以关注于如何响应用户的输入。 毕竟作为一个开发者, 我们更应该 关心如何对用户输入的信息进行分析处理,而不是重新造轮子来考虑如何去收集用户的输 入。如果应用程序需要一个按钮,只需要从工具箱中拖一个按钮出来放在界面上,然后在按 钮的点击事件中编写处理逻辑即可。 在大多数情况下, 开发者可能需要对按钮设置不同的外 观以响应用户鼠标的不同状态。WPF 会在底层上为我们实现这些事件,诸如鼠标何时悬停 在按钮上,或者被点击。 WPF 有一个健全的输入系统来从输入设备中获取用户的输入信息,并响应这些输入信息 所带来的控件变化。这些 API 位于 System.Windows.Input 命名空间中(Presentation.Core.dll), 这些 API 直接从操作系统获取输入设备输入的数据,例如,名为 Keyboard,Mouse,Stylus, Touch 和 Cursor 的这些类。InputManager 这个类负责管理所有输入设备获取的信息,并将这 些信息传递到表现框架中。 WPF 的另一类组件是位于 System.Windows 命名空间(PresentationCore.dll)下面的四个类, 他们是 UIElement,ContentElement,FrameworkElement 以及 FrameworkContentElement 。 FrameworkElement 继承自 UIElement,FrameworkContentElement 继承自 ContentElement。这 几个类是 WPF 中所有可视化元素的基类,如 Button,TextBlock 及 ListBox。更多 WPF 输入 系统相关信息可以参考 MSDN 文档。 InputManager 监听所有的输入设备,并通过一系列方法和事件来通知 UIElement 和 ContentElement 对象, 告知这些对象输入设备进行了一些有关可视化元素相关的操作。 例如, 在 WPF 中,当鼠标光标进入到可视化控件的有效区域时就会触发 MouseEnterEvent 事件。 UIElement 和 ContentElement 对象也有 OnMouseEnter 事件。这使得任何继承自 UIElement 或者 ContentElement 类的对象也能够接受来自输入设备的所触发的事件。WPF 会在触发任 何其它输入事件之前调用这些方法。 在 UIElement 和 ContentElement 类中也有一些类似的事 件包括 MouseEnter,MouseLeave,MouseLeftButtonDown,MouseLeftButtonUp, TouchEnter, TouchLeave,TouchUp 和 TouchDown。 有时候开发者需要直接访问鼠标或者其他输出设备, InputManager 对象有一个称之为 PrimaryMouseDevice 的属性。他返回一个 MouseDevice 对象。使用 MouseDevice 对象,能 够在任何时候通过调用 GetScreenPositon 来获取鼠标的位置。另外,MouseDevice 有一个名 为 GetPositon 的方法,可以传入一个 UI 界面元素,将会返回在该 UI 元素所在的坐标空间 中的鼠标位置。当需要判断鼠标悬停等操作时,这些信息尤其重要。当 Kinect SDK 每一次 产生一幅新的 SkeletonFrame 帧数据时,我们需要进行坐标空间转换,将关节点位置信息转 换到 UI 空间中去,使得可视化元素能够直接使用这些数据。当开发者需要将鼠标作为输入 设备时, MouseDevice 对象中的 GetScreenPositon 和 GetPosition 方法能提供当前鼠标所在 点的位置信息。 在有些情况下,Kinect 虽然和鼠标相似,但是某些方面差别很大。骨骼节点进入或者离 开 UI 上的可视化元素这一点和鼠标移入移出行为类似。换句话说,关节点的悬停行为和鼠 标光标一样。但是,类似鼠标点击和鼠标按钮的按下和弹起这些交互,关节点与 UI 的交互 是没有。在后面的文章中,可以看到使用手可以模拟点击操作。在 Kinect 中相对于实现鼠

标移入和移出操作来说,对鼠标点击这种支持相对来说较弱。 Kinect 和触摸板也没有太多相同的地方。触摸输入可以通过名为 Touch 或者 TouchDevice 的类来访问。单点的触摸输入和鼠标输入类似,然而,多点触控是和 Kinect 类似的。鼠标 和 UI 之间只有一个交互点(光标)但是触摸设备可以有多个触控点。就像 Kinect 可以有多 个游戏者一样。从每一个游戏者身上可以捕捉到 20 个关节点输入信息。Kinect 能够提供的 信息更多,因为我们知道每一个输入点是属于游戏者身体的那个部位。而触控输入设备,应 用程序不知道有多少个用户正在触摸屏幕。如果一个程序接收到了 10 个输入点,无法判断 这 10 个点是一个人的 10 个手指还是 10 个人的一个手指触发的。 虽然触控设备支持多点触 控,但这仍然是一种类似鼠标或者手写板的二维的输入。然而,触控输入设备除了有 X,Y 点坐标外,还有触控接触面积这个字段。毕竟,用户用手指按在屏幕上没有鼠标光标那样精 确,触控接触面积通常大于 1 个像素。 当然, 他们之间也有相似点。 Kinect 输入显然严格地符合 WPF 所支持的任何输入设备的 要求。 除了有其它输入设备类似的输入方式外, 他有独特的和用户进行交互的方式和图形用 户界面。核心上,鼠标,触控板和手写板只传递一个像素点位置嘻嘻你。输入系统确定该点 在可见元素上下文中的像素点位置, 然后这些相关元素响应这个位置信息, 然后进行响应操 作。 期望是在未来 Kinect 能够完整的整合进 WPF。在 WPF4.0 中,触控设备作为一个单独的 模块。最开始触控设备被作为微软的 Surface 引入。Surface SDK 包括一系列的 WPF 控件, 诸如 SurfaceButton, SurfaceCheckBox, 和 SurfaceListBox。 如果你想按钮能够响应触摸事件, 最好使用 SurfaceButton 控件。 能够想象到,如果 Kinect 被完整的整合进 WPF,可能会有一个称之为 SkeletonDevice 的类。 他和 Kinect SDK 中的 SkeletonFrame 对象类似。每一个 Skeleton 对象会有一个称之为 GetJointPoint 的方法,他和 MouseDevice 的 GetPositon 和 TouchDevice 的 GetTouchPoint 类 似 。 另 外 , 核 心 的 可 视 化 元 素 (UElement, ContentElement, FrameworkElement, FrameworkContentElement) 有能够相应的事件或者方法能够通知并处理骨骼关节点交互。 例 如,可能有一个 JointEnter,JointLeave,和 JointHover 事件。更进一步,就像触控类有一个 ManipulationStarted 和 ManipulationEnded 事 件 一 样 , 在 Kinect 输 入 的 时 候 可 能 伴 随 GetstureStarted 和 GestureEnded 事件。 目前,Kinect SDK 和 WPF 是完全分开的,因此他和输入系统没有在底层进行整合。所以 作为开发者的我们需要追踪骨骼关节点位置,并判断节点位置是否和 UI 界面上的元素有交 互。当关节点在对应的 UI 坐标系可视化界面的有效范围内时,我们必须手动的改变这些可 视化元素的外观以响应这种交互。 1.2 探测用户的交互 在确定用户是否和屏幕上的某一可视化元素进行交互之前,我们必须定义什么叫用户和 可视化元素的交互。 在以鼠标或者光标驱动的应用程序中有两种用户交互方式。 鼠标悬停和 点击交互。这些将事件划分为更精细的交互。就拿光标悬停来说,它必须进行可视化组件的 坐标空间区域,当光标离开这一区域,悬停交互也就结束了。在 WPF 中,当用户进行这些 操作时,会触发 MouseEnter 和 MouseLeave 操作。 除了点击和悬停外,鼠标还有另外一种常用的交互,那就是拖放。当光标移动到可视化组件 上方,按下鼠标左键,然后在屏幕上拖动,我们称之为拖动(drag),当用户松开鼠标左键时, 我们之位释放操作(drop)。鼠标拖动和释放是一个比较复杂的交互,这点和 Kinect 中的手势 类似。 本节我们来看一下一些简单的诸如光标悬停,进入,离开可视化控件的交互。在前篇文

章中的 Kinect 连线小游戏中,我们在绘制直线时需要判断手是否在点的合适范围内。在那 个小游戏中, 应用程序并没有像用户界面和人那样直接响应用户界的操作。 这种差别很重要。 应用程序在屏幕坐标空间中产生一些点的位置(数字),但是这些点并没有直接从屏幕空间派 生。这些点只是存储在变量中的数据而已。我们改变屏幕大小使得很容易展现出来。在接收 到新的骨骼数据帧之前。 骨骼数据中手的位置被转换到屏幕中点所在的空间坐标系, 然后我 们判断手所在的位置的点是否在点序列中。 技术上来讲, 这个应用程序即使没有用户界面也 能够正常运行。 用户界面是动态的由这些数据产生的。 用户直接和这些数据而不是和界面进 行交互。 1.2.1 命中测试(Hit testing) 判断用户的手是否在点的附近远没有判断手是否在点的位置上那么简单。每一个点只是 一个象元。 为了使得应用程序能够工作。 我们并不要求手的位置敲好在这个点所在的象元上, 而是要求在以这个点为中心的某一个区域范围内。 我们在点的周围创建了一个圆圈代表点的 区域范围, 用户的手的中心必须进入到这个点的区域范围才被认为是悬停在该点上。 如图所 示在圆形中的白色的点是实际的点, 虚线绘制的圆形是该点的最大可触及范围。 手形图标的 中心用白色的点表示。所以,有可能手的图标和点的最大范围接触了,但是手的中心却不在 该点的最大范围内。判断手的中心是否在点的最大范围之内称之为命中测试。

在 Kinect 连线游戏中,用户界面响应数据,依据产生的坐标将点绘制在图像界面上,系 统使用点而不是用可视化控件的有效区间来进行命中测试。 大多数的应用程序和游戏都不是 这样做的。用户界面通常很复杂,而且是动态的。例如在 Kinect for Windows SDK 中自带的 ShapeGame 应用就是这样一个例子,它动态的从上至下产生一些形状。当用户触碰这些形 状时形状会消失或者弹开。

ShapeGame 这个应用比之前的 Kinect 连线游戏需要更为复杂的命中测试算法。 WPF 提供 了 一 些 工 具 来 帮 助 我 们 实 现 命 中 测 试 。 在 System.Windows.Media 命 名 空 间 下 的 VisualTreeHelper 帮助类中有一个 HitTest 方法。这个方法有很多个重载,但是最基本的方法 接受两个参数,一个是可视化控件对象,另一个是待测试的点。他返回可视化对象树中该点 所命中的最顶层的那个可视化对象。听起来可能有点复杂,一个最简单的解释是,在 WPF 中有一个分层的可视化输出,有多个对象可能占据同一个相对空间,但是在不同的层。如果 该点所在位置有多个对象,那么 HitTest 返回处在可视化树中处在最顶层的可视化对象。由 于 WPF 的样式和模板系统使得一个控件能够由一个或者多个元素或者其它控件组成,所在 通常在一个点可能有多个可视化元素。

上图可能帮助我们理解可视元素的分层。图中有三个元素:圆形,矩形和按钮。所有三 个元素都在 Canvas 容器中。圆形和按钮在矩形之上,左边第一幅图中,鼠标位于圆形之上, 在这点上的命中测试结果将返回这个圆形。第二幅图,即使矩形最底层,由于鼠标位于矩形 上,所以命中测试会返回矩形。这是因为矩形在最底层,他是唯一个占据了鼠标光标象元所 在位置的可视化元素。在第三幅图中,光标在按钮的文字上,命中测试将返回 TextBlock 对 象,如果鼠标没有位于按钮的文字上,命中测试将会返回 ButtonChrome 元素。按钮的可视 化表现通常由一个或者多个可视化控件组成,并能够定制。实际上,按钮没有继承可视化样 式,它是一个没有可视化表现的控件。上图中的按钮使用的是默认样式,它由 TextBlock 和 ButtonChrome 这两个控件构成的。在这个例子中,我们通常会获得到有按钮样式组成的元 素,但是永远获取不到实际的按钮控件。 为了使得命中测试更为方便,WPF 提供了其他的方法来协助进行命中测试。UIElement 类定义了一个 InputHitTest 方法,它接受一个 Point 对象,并返回该 Point 对象指定的一个 IIputElement 元素。UIElement 和 ContentElement 两个类都实现了 IInputElement 接口。这意 味着所有的 WPF 用户界面元素都实现了这个接口。VisualTreeHelper 类中的 HitTest 方法可 以用在一般的场合。 Note: MSDN 中关于 UIElement.InputHitTest 方法的建议“应用程序一般不需要调用该方法, 只有应用程序需要自己重新实现一系列已经实现了的底层输入特征, 例如要重新实现鼠标设 备的输入逻辑时才会去调用该方法。”由于 Kinect 并没有原生的集成到 WPF 中,所以必须 重新实现类似鼠标设备的输入逻辑。 WPF 中,命中测试依赖于两个变量,一个是可视化元素,另一个是点。测试首先该点转 换到可视化元素所在坐标空间, 然后确定是否处于该可视化元素的有效范围内。 下图可以更 好的理解可视化元素的坐标空间。WPF 中的每一个可视化元素,不论其形状和大小,都有 一个外轮廓:这个轮廓是一个矩形,它包含可视化元素并定义了可视化元素的宽度和高度。 布局系统使用这个外轮廓来确定可视化元素的整体尺寸以及如何将其排列在屏幕上。 当开发 者使用 Canvas,Grid,StackPanel 等容器来布局其子元素时,元素的外轮廓是这些容器控件 如进行布局计算的基础。用户看不到元素的外轮廓,下图中,可视化元素周围的虚线矩形显 示了这些元素的外轮廓。此外,每一个元素有一个 X,Y 坐标用来指定该元素在其父容器中 的位置。可以通过 System.Windows.Controls.Primitives 命名空间中的 LayoutInformation 静态 类中的 GetLayoutSlot 方法来获取元素的外轮廓和其位置。举例来说,图中三角形的外轮廓 的左上角坐标点为(0,0) ,三角形的宽和高都是 200 像素。所以在三角形外轮廓中,三角形 的三个点的坐标分别为(100,0),(200,200),(0,200)。并不是在三角形外轮廓中的所有点在命中 测试中都会成功,只有在三角形内部的点才会成功。点 (0,0) 不会命中,而三角形的中心 (100,100)则能命中。

命中测试的结果依赖于可视化元素的布局。在目前所有的项目中,我们使用 Canvas 容器 来包含所有可视化元素。Canvas 是一个可视化的容器,能够使得开发者对可视化元素的位 置进行完全控制,这一点在使用 Kinect 的时候尤其明显。像手部跟踪这类基本的方法也可 以使用 WPF 中的其他容器,但是需要做很多其他工作,并且性能没有使用 Canvas 好。使用 Cnavas 容器,用户可以通过 CanvasLeft 和 CanvasTop 显式设定其所有子元素的起始 X,Y 的 位置。前面讨论的坐标空间转换使用 Cnavas 作为容器,因为不需要太多的处理操作,转换 也非常明了,只需要少量的代码就可以实现较好的性能。 使用 Canvas 作为容器的缺点也是其的优点。由于开发者可以完全控制在 Canvas 中子元 素的位置, 所以当窗体大小发生改变或者有比较复杂的布局时, 也需要开发者去更新这些可 视化元素的位置。而另外一些容器控件,如 Grid,StackPanel 则会帮助我们实现这些更新操 作。 但是, 这些容器控件增加了可视化树的结构和坐标空间, 从而增加了命中测试的复杂度。 坐标空间越多,需要的点的转换就越多。这些容器还有 alignment 属性(水平和垂直)和相 对于 FrameworkElement 的 margin 属性,进一步增加了命中测试的计算复杂度。如果可是化 元素有 RenderTransforms 方法的话, 我们可以直接使用这些方法而不用去自己写命中测试的 算法了。 一个折中的方法是, 将那些基于骨骼节点位置的需要频繁变化的可视化元素, 如手形图标 放在 Canvas 容器内, 而将其他 UI 元素放在其他容器控件内。 这种布局模式需要多个坐标空 间转换,会影响程序性能,并且在进行坐标空间转换计算时可能会引入一些 bug。这种混合 的布局方案在大多数情况下是最好的选择,它充分利用了 WPF 布局系统的优点。要详细了 解各种容器及其命中测试的相关概念,可以参阅 MSDN 中 WPF 的布局系统。 1.2.2 响应输入 命中测试只能告诉当前用户输入点是否在可视化元素的有效区间内。用户界面最重要的 一个功能是要给予用户一些对输入操作的反馈。 当鼠标移到一个按钮上时, 我们期望按钮能 够改变其外观,告诉我们这个按钮是可以点击的。如果没有这种反馈,用户不仅用户体验不

好,而且还会使用户感到迷惑。有时候即使功能完备,用户体验失败意味着应用的失败。 WPF 有一套功能强大的系统来通知和响应用户的输入。只要用户的输入设备是鼠标,手 写笔,触摸板这些标准设备,WPF 的样式和模版系统使得开发出能够响应用户输入的高度 的定制化的用户界面非常容易。而 Kinect 的开发者有两种选择:不使用 WPF 系统提供的功 能,手动实现所有功能,或者创建一个通用的控件来响应 Kinect 输入。第二种方法虽然不 是特别难,但是初学者也不太容易能够实现。 了解了这一点,在下面的章节中,我们将会开发一个游戏来使用命中测试并手动响应用 户的输入。在开始之前,思考一个问题,到目前位置,还有那些问题没有很好解决?使用 Kinect 骨骼数据和用户界面进行交互是什么意思?核心的鼠标交互有:进入,离开和点击。 触摸输入交互有进入,离开,按下,弹起。鼠标只有一个触控点,触摸版可以有多个触控点, 但是只有一个是主触控点。Kinect 骨骼节点数据有 20 个可能的数据点,哪一个点是主触控 点?应该有一个主控点吗?一个可视化元素, 比如说按钮, 会在任何一个关节点数据到达按 钮的有效范围内触发,还是只是特定的关节点数,比如手,进入范围后才能触发? 没有一个回答能够完全回答好上面的问题。这取决于应用程序界面的设计及要实现的功 能。这些问题其实是自然交互界面设计中的一部分典型问题。在后面我们会介绍。对于大多 数 Kinect 应用程序,包括本文中的例子,只允许手部关节点数据才能和用户界面进行交互。 最开始的交互是进入和离开。 除此之外的交互可能会很复杂。 在后面我们将介绍这些复杂的 交互,现在让我们来看看最基本的交互。 2. “我说你做”游戏 为了演示如何将 Kinect 作为一个输入设备,我们开始开发我们的项目:该项目使用手部 关节点数据模仿鼠标或者触控板和用户界面进行交互。 这个项目的目标是展示如何进行命中 测试和使用 WPF 可视化元素来创建用户界面。项目是一个称之为“我说你做”(Simon Say)的 小游戏。 “我说你做”(Simon says)是一个英国传统的儿童游戏。一般由 3 个或更多的人参加。其 中一个人充当"Simon"。其他人必须根据情况对充当"Simon"的人宣布的命令做出不同反应。 如果充当"Simon"的人以"Simon says"开头来宣布命令,则其他人必须按照命令做出相应动 作。如:充当"Simon"的人说:"Simon says jump(跳)"。其他人就必须马上跳起;而如果 充当"Simon"的人没有说"Simon says"而直接宣布命令,如:充当"Simon"的人说"jump"。则 其他人不准有动作,如果有动作则做动作的人被淘汰出游戏。 在 70 年代末 80 年代初有一个叫 Milton Bradley 的游戏公司开发了一个电子版的 Simon say 游戏。该游戏界面由 4 个不同颜色 (红色,蓝色,绿色,黄色) 的按钮组成,这个游戏在电 脑上运行,让游戏者按演示的顺序按下这些按钮。当开始游戏时,程序首先按照一定的顺序 亮起每一个按钮, 游戏者必须按照这个亮灯的顺序依次按下这些按钮。 如果游戏者操作正确, 那么下一个亮灯序列又开始, 到后面变化会越来越快, 直到游戏者不能够按照给定的顺序按 下这些按钮位置。 我们要做的是,使用 Kinect 设备来实现这么一个 Simon Say 游戏。 这是个很好的使用 Kinect 展示如何和用户界面进行交互的例子。 这个游戏也有一些规则。 下图展示了我们将要做的用 户界面,他包含四个矩形,他用来模拟游戏中的按钮。界面上方是游戏标题,中间是游戏的 操作指南。

这个 Kinect 版的 Simon says 游戏追踪游戏者的手部关节。当用户的手碰到了这四个填充 了颜色的方框中的任何一个时,程序认为游戏者按下了一个按钮。在 Kinect 应用程序中, 使用悬停或者点击来和按钮进行交互很常见。现在,我们的游戏操作指南还很简单。游戏一 开始, 我们提示用户将手放在界面上红色矩形中手势图标所在的位置。 在用户将双手放到指 定位置后,界面开始发出指令。如果游戏者不能够重复这个过程,游戏将会结束,并返回到 这个状态。现在,我们对这个游戏的概念,规则和样子有了一些了解,现在开始编码。 2.1 Simon say “设计一个用户界面” 首先来设计一个用户界面, 下面的代码展示的主界面中的 XAML 和之前的连线游戏一样, 我们将所有的主界面的 UI 元素包含在 Viewbox 容器中,让他来帮助我们进行不同显示器分 辨率下面的缩放操作。主 UI 界面分辨率设置为 1920*1080。UI 界面共分为 4 个部分:标题 及游戏指导,游戏界面,游戏开始界面以及用来追踪手部的手形图标。第一个 TextBlock 用 来显示标题,游戏引导放在接下来的 StackPanel 元素中。这些元素是用来给游戏者提供当前 游戏状态。他们没有功能性的作用,和 Kinect 或者骨骼追踪没有关系。 GameCanvas,ControlCanvas 和 HandCanvas 包含了所有的和 Kienct 相关的 UI 元素,这 些 元素 是基于 当前 用户手 的位 置和用 户界面 进行 交互 的。手 的位置 来自 骨骼 追踪。 HandCanvas 应该比较熟悉,程序中有两个手形图标,用来追踪游戏者两只手的运动。 ControlCanvas 存储的 UI 元素用来触发开始游戏。GameCanvas 用来存储这 4 个矩形,在游 戏中,用户需要点击这些矩形。不同的交互元素存储在不同的容器中,使得用户界面能够比 较容易使用代码进行控制。比如,当用户开始游戏后,我们需要隐藏所有的 ControlCanvas 容器内的子元素,显然隐藏这个容器比隐藏其每个子控件容易的多。整个 UI 代码如下: <Window x:Class="KinectSimonSay.MainWindow" xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation" xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"

xmlns:c="clr-namespace:KinectSimonSay" Title="MainWindow" WindowState="Maximized"> <Viewbox> <Grid x:Name="LayoutRoot" Height="1080" Width="1920" Background="White" TextElement.Foreground="Black"> <c:SkeletonViewer x:Name="SkeletonViewerElement"/> <TextBlock Text="Simon Say" FontSize="72" Margin="0,25,0,0" HorizontalAlignment="Center" VerticalAlignment="Top"></TextBlock> <StackPanel HorizontalAlignment="Center" VerticalAlignment="Center" Width="600"> <TextBlock x:Name="GameStateElement" FontSize="55" Text=" GAME OVER!" HorizontalAlignment="Center" /> <TextBlock x:Name="GameInstructionsElement" Text="将手放在对象上开 始游戏。" FontSize="45" HorizontalAlignment="Center" TextAlignment="Center" TextWrapping="Wrap" Margin="0,20,0,0" /> </StackPanel> <Canvas x:Name="GameCanvas"> <Rectangle x:Name="RedBlock" Height="400" Width="400" Fill="Red" Canvas.Left="170" Canvas.Top="90" Opacity="0.2" /> <Rectangle x:Name="BlueBlock" Height="400" Width="400" Fill="Blue" Canvas.Left="170" Canvas.Top="550" Opacity="0.2" /> <Rectangle x:Name="GreenBlock" Height="400" Width="400" Fill="Green" Canvas.Left="1350" Canvas.Top="550" Opacity="0.2" /> <Rectangle x:Name="YellowBlock" Height="400" Width="400" Fill="Yellow" Canvas.Left="1350" Canvas.Top="90" Opacity="0.2" /> </Canvas> <Canvas x:Name="ControlCanvas"> <Border x:Name="RightHandStartElement" Background="Red" Height="200" Padding="20" Canvas.Left="1420" Canvas.Top="440" > <Image Source="Images/hand.png" /> </Border> <Border x:Name="LeftHandStartElement" Background="Red" Height="200" Padding="20" Canvas.Left="300" Canvas.Top="440" > <Image Source="Images/hand.png" > <Image.RenderTransform> <TransformGroup> <TranslateTransform X="-130" /> <ScaleTransform ScaleX="-1" /> </TransformGroup> </Image.RenderTransform> </Image>

</Border> </Canvas> <Canvas x:Name="HandCanvas"> <Image x:Name="RightHandElement" Visibility="Collapsed" Height="100" Width="100" /> <Image x:Name="LeftHandElement" Visibility="Collapsed" Height="100" Width="100" > <Image.RenderTransform> <TransformGroup> <ScaleTransform ScaleX="-1" /> <TranslateTransform X="90" /> </TransformGroup> </Image.RenderTransform> </Image> </Canvas> </Grid> </Viewbox> </Window>

Source="Images/hand.png" Source="Images/hand.png"

2.2 Simon say “构建程序的基础结构” UI 界 面 设计 好了 之后 , 我们 现在 来看 游戏的 基 础结 构。 需要 在代码 中 添加 响应 SkeletonFrameReady 事件的逻辑。 在 SkeletonFrameReady 事件中, 添加代码来跟踪游戏者手 部关节的运动。基本代码如下: private void KinectDevice_SkeletonFrameReady(object sender, SkeletonFrameReadyEventArgs e) { using (SkeletonFrame frame = e.OpenSkeletonFrame()) { if (frame != null) { frame.CopySkeletonDataTo(this.frameSkeletons); Skeleton skeleton = GetPrimarySkeleton(this.frameSkeletons); if (skeleton == null) { ChangePhase(GamePhase.GameOver); } else { LeftHandElement.Visibility = Visibility.Collapsed; RightHandElement.Visibility = Visibility.Collapsed; }

} } } private static Skeleton GetPrimarySkeleton(Skeleton[] skeletons) { Skeleton skeleton = null; if (skeletons != null) { //Find the closest skeleton for (int i = 0; i < skeletons.Length; i++) { if (skeletons[i].TrackingState == SkeletonTrackingState.Tracked) { if (skeleton == null) { skeleton = skeletons[i]; } else { if (skeleton.Position.Z > skeletons[i].Position.Z) { skeleton = skeletons[i]; } } } } } return skeleton; } 上面代码中 TrackHand 和 GetJointPoint 代码和 Kinect 连线游戏中相同。对于大多数游戏 来说,使用“拉模型”来获取数据比使用事件模型获取数据性能要好。游戏通常是一个循环, 可以手动的从骨骼数据流中获取下一帧骨骼数据。 但是在我们的例子中, 仍然使用的是事件 模型,为的是能够减少代码量和复杂度。 2.3 Simon say “添加游戏基本元素” Simon say 游戏分成三步。起始步骤,我们之为 GameOver,意味着当前没有可以玩的游 戏。这是游戏的默认状态。这也是当 Kinect 探测不到游戏者时所切换到的状态。然后游戏 开始循环,Simon 给出一些指令,然后游戏者重复执行这些指令,重复这一过程,直到用户 没能够正确的执行 Simon 给出的指令为止。应用程序定义了一个枚举变量来描述游戏所有 可能的状态, 以及定义了一个变量来跟踪游戏这当前所执行了的指令位置。 另外我们需要一 个变量来描述游戏者成功的次数或者游戏等级。当游戏者成功的执行了 Simon 给出的指令 后,这个变量加 1。下面的代码展示了这个枚举以及变量,变量的初始化在类的够着函数中

执行。 public enum GamePhase { GameOver = 0, SimonInstructing = 1, PlayerPerforming = 2 } public MainWindow() { InitializeComponent(); KinectSensor.KinectSensors.StatusChanged += KinectSensors_StatusChanged; this.KinectDevice = KinectSensor.KinectSensors.FirstOrDefault(x => x.Status == KinectStatus.Connected); ChangePhase(GamePhase.GameOver); this.currentLevel = 0; } SkeletonFrameReady 事件需要根据当前游戏所处的状态来执行不同的操作。下面的代码 中根据当前游戏的状态执行 ChangePhase, ProcessGameOver 和 ProcessPlayerPerforming 子方 法。 这些方法的详细执行过程将在后面介绍。 ChangePhase 方法接受一个 GamePhase 枚举值, 后两个方法接受一个 Skeleton 类型的参数。 当应用程序探测不到骨骼数据时,游戏会终止,并切换到 Game Over 阶段。当游戏者离 开 Kinect 视野时会发生这种情况。当游戏处在 Simon 给出操作步骤阶段时,隐藏界面上的 手势图标。否则,更新这两个图标的位置。当游戏处在其它状态时,程序基于当前特定的游 戏阶段调用特定的处理方法。 private void KinectDevice_SkeletonFrameReady(object sender, SkeletonFrameReadyEventArgs e) { using (SkeletonFrame frame = e.OpenSkeletonFrame()) { if (frame != null) { frame.CopySkeletonDataTo(this.frameSkeletons); Skeleton skeleton = GetPrimarySkeleton(this.frameSkeletons); if (skeleton == null) { ChangePhase(GamePhase.GameOver); } else { if (this.currentPhase == GamePhase.SimonInstructing) { LeftHandElement.Visibility = Visibility.Collapsed; RightHandElement.Visibility = Visibility.Collapsed;

} else { TrackHand(skeleton.Joints[JointType.HandLeft], LayoutRoot); TrackHand(skeleton.Joints[JointType.HandRight], LayoutRoot); switch (this.currentPhase) { case GamePhase.GameOver: ProcessGameOver(skeleton); break; case GamePhase.PlayerPerforming: ProcessPlayerPerforming(skeleton); break; } } } } } } 2.4 开始新游戏 当游戏处在 GameOver 阶段时,应用程序只调用了一个方法:该方法判断用户是否想玩 游戏。当用户将相应的手放在 UI 界面上手势所处的位置时,游戏开始。左右手需要分别放 在 LeftHandStartElement 和 RightHandStartElement 所处的位置内。在这个例子中,我们使用 WPF 自带的命中测试功能。我们的 UI 界面很小也很简单。InputHitTest 操作所需要处理的 UI 元素很少,因此性能上没有太大问题。下面的代码展示了 ProcessGameOver 方法和 GetHitTarget 方法。 private void ProcessGameOver(Skeleton skeleton) { //判断用户是否想开始新的游戏 if (HitTest(skeleton.Joints[JointType.HandLeft], LeftHandStartElement) &amp;& HitTest(skeleton.Joints[JointType.HandRight], RightHandStartElement)) { ChangePhase(GamePhase.SimonInstructing); } } RightHandElement, LeftHandElement,

private bool HitTest(Joint joint, UIElement target) { return (GetHitTarget(joint, target) != null);

} private IInputElement GetHitTarget(Joint joint, UIElement target) { Point targetPoint = LayoutRoot.TranslatePoint(GetJointPoint(this.KinectDevice, joint, LayoutRoot.RenderSize, new Point()), target); return target.InputHitTest(targetPoint); } ProcessGameOver 方法的逻辑简单明了:如果游戏者的任何一只手在 UI 界面上的对应位 置,就切换当前游戏所处的状态。GetHitTarget 方法用来测试给定的关节点是否在可视化控 件有效范围内。他接受关节点数据和可视化控件,返回该点所在的特定的 IInputElement 对 象。虽然代码只有两行,但是了解背后的逻辑很重要。 命中测试算法包含三个步骤, 首先需要将关节点所在的骨骼空间坐标系中坐标转换到对 应的 LayoutRoot 元素所在的空间坐标中来。 GetJointPoint 实现了这个功能。其次,使用 UIElement 类中的 TranslatePoint 方法将关节点从 LayoutRoot 元素所在的空间坐标转换到目 标元素所在的空间坐标中。最后,点和目标元素在一个坐标空间之后,调用目标元素的 InputHitTest 方法,方法返回目标对象树中,点所在的确切的 UI 元素,任何非空值都表示命 中测试成功。 注意到逻辑之所以这么简单是因为我们采用的 UI 布局方式, 应用程序假定全屏运行并且 不能调整大小。将 UI 界面设置为静态的,确定大小能够极大的简化计算量。另外,将所有 的可交互的 UI 元素放在 Canvas 容器内使得我们只有一个坐标空间。 使用其他容器空间来包 含元素或者使用诸如 Hor

相关文章:
Kinect for Windows SDK开发入门
Kinect for Windows SDK 软硬件环境 Kinect for Windows SDK 是一些列的类库, 他能够使得开发者能够将 Kinect 作为输入设备开 发各种应用程序。就像名字所显示的...
Kinect for Windows SDK开发入门(一):开发环境配置
Kinect for Windows SDK开发入门(一):开发环境配置_计算机软件及应用_IT/计算机_专业资料。[译]Kinect for Windows SDK 开发入门(一):开发环境配置前几天无意中...
Kinect for Windows SDK开发入门(三):基础知识 下
Kinect for Windows SDK开发入门(三):基础知识 下_计算机软件及应用_IT/计算机_专业资料。[译]Kinect for Windows SDK 开发入门(三):基础知识 下 1. 性能改进...
Kinect for Windows SDK开发入门(二):基础知识 上
Kinect for Windows SDK开发入门(二):基础知识 上_计算机软件及应用_IT/计算机_专业资料。[译]Kinect for Windows SDK 开发入门(二):基础知识 上 上篇文章介绍了...
Kinect for Windows SDK开发入门(十五):进阶指引 下
Kinect for Windows SDK开发入门(十五):进阶指引 下_计算机软件及应用_IT/计算机_专业资料。[译]Kinect for Windows SDK 开发入门(十五):进阶指引 下 上一篇文章...
[译]Kinect for Windows SDK开发入门(一):开发环境配置
Kinect for Windows SDK 软硬件环境 Kinect for Windows SDK 是一些列的类库,他能够使得开发者能将 Kinect 作为输入设备开发 各种应用程序。就像名字所显示那样,...
Kinect for Windows SDK开发入门(十四):进阶指引 上
[译]Kinect for Windows SDK 开发入门(十四):进阶指引 上 前面十三篇文章介绍了 Kinect SDK 开发中的各个方面的最基础的知识。正如本系列博闻标题那样,这些知识...
Kinect for Windows SDK开发入门(十二):语音识别 上
Kinect for Windows SDK开发入门(十二):语音识别 上_计算机软件及应用_IT/计算机_专业资料。[译]Kinect for Windows SDK 开发入门(十二):语音识别 上 Kinect 的...
Kinect for Windows SDK开发入门(六):骨骼追踪基础 上
[译]Kinect for Windows SDK 开发入门(六):骨骼追踪基础 上 Kinect 产生的景深数据作用有限,要利用 Kinect 创建真正意义上交互,有趣和难忘的应用,还需要除了深度...
Kinect for Windows SDK开发入门(十八):Kinect Interaction交互控件
Kinect for Windows SDK开发入门(十八):Kinect Interaction交互控件_计算机软件及应用_IT/计算机_专业资料。[译]Kinect for Windows SDK 开发入门(十八):Kinect ...
更多相关标签:
kinect2.0开发入门 | kinect开发入门 | kinect unity开发入门 | kinect v1.8 sdk开发 | kinect sdk 2.0 开发 | kinect sdk 开发教程 | kinect windows 开发 | windows驱动开发入门 |