Leap Motion开发虚拟鼠标

Oct 06, 2013

前言

基友把他的LeapMotion落在我家了.妈妈前段时间过来一起去宜家买了个扶椅.椅子很舒服,但是有些矮,没地儿放鼠标.LeapMotion有些帅,但Linux下几乎找不到应用.那我用LeapMotion来做一个虚拟鼠标放椅子上吧!

相关基础

  • 平台:Linux
  • 语言:coffeescript, python, bash
  • 依赖:leapJs, python-xlib, leapSDK, ZeroMQ
  • 其他:简单的几何知识

目标

首先, 明确设计目标:实际可用. 实际可用的意思是:

设置可以麻烦些,代码可以脏些,可以没有界面,可以没有通用性(只适合前言中提到的在躺椅上的使用场景).

…但是,确实能够使用,不能有误操作,开关方便,姿势舒胡.

知己知彼方可百战不怠, 不过既然是硬件就千万别相信什么官方参数和宣传视频, 况且要知道Linux下的驱动可是慢了两个小版本号的…

官方参数

好吧, 有总比没有的好, 还是从官方参数下手.

这里是需要注册才能浏览的官方特供javascript文档.

能获取的有以下几种参数.

  • Hands — All hands.
  • Pointables — All fingers and tools as Pointable objects.
  • Fingers — All the fingers.
  • Tools — All the tools.
  • Gestures — All the gestures that started, ended, or which had an update.

官方说法是可以识别高度50mm-600mm内的物体. 宽度是根据高度来确定的. 我个人估计, 高度和可识别宽度的关系可以想象成双曲线的一侧弧. 在坐在椅子上的实际测量上的最大识别高度是700mm最低高度是50mm无误.

设计

制作虚拟鼠标主要需要的是定位+点击操作. 其中Hand/Pointable/Finger/Tool全部都可以用来定位. 显然越简单的方式越不容易出错, 所以定位我选择Hand.

定位有两种思路:

  1. 类似数位板一样采用绝对定位. 也就是说手相对LeapMotion的位置, 即是鼠标的位置.
  2. 采用轨迹球或者ThinkPad小红点一样的加速模型. 手偏离”中心” 的位置即对应鼠标在对应方向上的加速度 (v+a*t)*drag .

我选用2. , 因为2可以很好的过滤掉手抖带来的问题,而1. 则很难做到.

点击操作也有两个思路:

  1. Hand的数据里有一些额外的信息, 比如palmNormal(手掌的方向)和sphereRadius(手形成的球的半径), 利用这些数据的变化, 触发单击操作.
  2. 使用Gestures来判断, Gestures 包括Circel,Tap.

但在开发的过程中这两个思路都被否定了. 不管是前者还是后者在引发操作的时候都很难维持鼠标位置, 因此会导致严重的误操作. 而且其中Hand在左右移动手的时候容易误触发, Gesture也有这个问题. 所以我使用了一个很蠢但是确实好用的办法:

  • 用键盘触发点击.

基本的方案确定了, 接下来是具体的模型.

省略若干尝试和思考, 直奔结果. 首先是使用场景

设计图2

使用场景没有特别需要解释的部分.我们与LeapMotion的交互主要发生在Interact zone内.通过手在Interact zone里面的操作来影响鼠标.

Interact zone 是一个椭圆. 除了椭圆我们还有方形或其他圆锥曲线的选择. 不选则方形是因为LeapMotion的识别空间其实更接近一个抛物线/双曲线的形状. 用方形建模要处理很多糟糕的边界问题, 也不符合人体的直觉, 我们对鼠标的操作不是分为x和y轴的, 而是一个向量, (虽然最后总是要分解的) 鼠标往任意方向的最大速度总应该是相同的. 不采用抛物线和双曲线的原因是, Leap Motion的最大识别高度是有限的的, 我们可以方便的构造一个接近识别空间的椭圆, 而不是一个没有上界的曲线.

不采用圆形的原因更感性. 总的来说, 当手肘放在扶手上时, 人手向上移动的幅度总是会比向下移动的大, 为了符合人体直觉, 应该针对移动的幅度和实际鼠标的速度做一个非线性的映射, 从而向上控制移动更大的距离并带来较小的速度, 最终贴近实际操作体验. 圆形是没办法构造这个映射的.

接下来我们来把Interact zone映射到我们的鼠标加速度空间去. 我们来看一下映射的思路. 映射分两步, 首先把识别空间的椭圆规范化映射到一个单位圆内部. 之后再映射到鼠标的加速度. 我们先看如何映射到单位圆. 设计图1

Interact zone是一个椭圆. 上端点是LeapMotion的最大识别高度, 下端点是LeapMotion的最小识别高度, 下焦点是Base Height 也就是我们手肘架在扶手上, 手抬起时最舒适的高度.

O点是Interact zone的下焦点. 正常使用的时候手处在这里表示没有速度. 以O点为圆心做单位圆. 如图所示我们需要把T1映射到R1点. 这里正确的做法是根据PE-O与PR-O的比例来映射T1和R1. 但是这样需要解PE点的位置, 是的我很讨厌用写这种代码. 于是我用T1’-O’ 和T1-O’的比例来近似PR-O’到R1-O 的比例,实际上他们不会差太远. 但是T1’ 和PR的位置都非常好求. T1带入椭圆方程取倒数就可以换算出T1’和T1的坐标比. PR的坐标就是T1-O对应的tan,cot.

任何一个在Interact zone内的点都可以用这种方式映射到单位圆内去. 当手偶然移动到单位圆外时, 如T2,T2’所示, 先规范化到T2’, 然后再用跟T1一样的算法映射到R2.

现在我们总可以根据手的位置得到一个模小于1的向量Vr. 接下来我要映射到之前提到的加速空间中去. 这里跳过无数人生的思考和过去几十年的积累, 总之我们选择了一个简单的小学生公式 v = (v0+a*dT)*drag. 我年轻的时候也曾执着于更加精确的积分, 和更加真实的模型, 相信我, 他们没什么用. 如果要根据非定长的离散dT做精确运算那是另外一个世界的故事, 不过根据中国游戏业目前的发展趋势来说, 我们还是忘了它吧.

公式中,v0是当前鼠标的速度. dT可以是两次计算之间的时间间隔, 最好是一个定值或者相似的一串值, 否则这个公式就是一坨屎. a 则象征着加速度的值, 他是根据我们之前得到的Vr向量计算出来的, drag是一个小于1大于0的系数. 这个公式的特点是, 每一次迭代v 都会更趋近于v0. 此时 v = a*dT/(1-drag). 也就是说a*dT越大最终的速度越大. 但并不是一瞬间就到达速度,而是逐渐的快速的趋近该速度. 相应的手回到中央后由于a为0, 因此鼠标也会慢慢的停下来. 熟悉ThinkPad小红点的同学应该会非常喜欢这个模型.

最后一步, 也是需要大量Trial And Error的一步就是确定Vr到a的映射. 大体上可以总结为这样一个算式:ax = Vrx^factor * maxSpeed, 其中Vrx是Vr在x或y轴上的分量. factor 应该是一个大于1的数, maxSpeed则是最大速度(并不是等于最大速度,而是一个系数).

函数

大学挂课太多的时候老师就会使用(分数^0.5)*10 的方式,重新计算挂课分布. 这和我们的原理极其相似. 当Factor越大的时候,那么在Vr小的时候函数斜率小, 也就是非常愚钝, 这样我们就可以更精确的控制鼠标的小范围定位, 而当Vr接近1的时候, 增长的非常快, 这样我们就可以快速的移动鼠标. 当然这对factor/maxpeed值是需要不断调试的. Factor一般在2-5之间, maxSpeed则跟帧率dT有关系.

实现

写代码总是最简单的部分,说些比较麻烦的地方.在Linux下nodejs并没有很好的跟Xserver交互的方式, 所以我是用python实现了一个进行FakeInput的中间层调用Xlib,然后node通过ZeroMQ来和它交互. 这部分很简单. 之前提到过的我采用键盘来控制点击, 因此nodejs必须实现键盘的获取. 找了一圈后, 我还是选择直接从/dev/input/eventX读. ubuntu下具体X对应的数字可以根据 cat /proc/bus/input/device 的输出来决定, 比如

: Bus=0000 Vendor=0000 Product=0000 Version=0000
N: Name="HDA NVidia HDMI/DP,pcm=7"
P: Phys=ALSA
S: Sysfs=/devices/pci0000:00/0000:00:02.0/0000:01:00.1/sound/card1/input11
U: Uniq=
H: Handlers=event11 
B: PROP=0
B: EV=21
B: SW=140

I: Bus=0000 Vendor=0000 Product=0000 Version=0000
N: Name="HDA NVidia HDMI/DP,pcm=3"
P: Phys=ALSA
S: Sysfs=/devices/pci0000:00/0000:00:02.0/0000:01:00.1/sound/card1/input12
U: Uniq=
H: Handlers=event12 
B: PROP=0
B: EV=21
B: SW=140

I: Bus=0003 Vendor=046a Product=0011 Version=0111
N: Name="HID 046a:0011"
P: Phys=usb-0000:00:16.0-3/input0
S: Sysfs=/devices/pci0000:00/0000:00:16.0/usb7/7-3/7-3:1.0/input/input28
U: Uniq=
H: Handlers=sysrq kbd event2 
B: PROP=0
B: EV=120013
B: KEY=1000000000007 ff980000000007ff febeffdfffefffff fffffffffffffffe
B: MSC=10
B: LED=7

I: Bus=0003 Vendor=0461 Product=4d80 Version=0111
N: Name="USB Optical Mouse"
P: Phys=usb-0000:00:12.0-3/input0
S: Sysfs=/devices/pci0000:00/0000:00:12.0/usb4/4-3/4-3:1.0/input/input33
U: Uniq=
H: Handlers=mouse0 event3 
B: PROP=0
B: EV=17
B: KEY=70000 0 0 0 0
B: REL=143
B: MSC=10

在我的系统对应的就是N: Name="HID 046a:0011" 也就是event2. 如果不确定可以sudo cat /dev/input/event2. 如果键盘操作的时候有乱码输出就对了.

这个设备文件可以直接用node读.

fs = require "fs"
jParser = require "jParser"
keyboardPath = "/dev/input/event2"
module.exports = new (require("events").EventEmitter);
writer = fs.createWriteStream(keyboardPath)
stream = fs.createReadStream(keyboardPath)
stream.on "end",()->
    console.log "end"
stream.on "error",(err)->
    console.error err
stream.on "data",(data)->
    event = {
        event:{
            time:["array","int32",4]
            ,type:"int16"
            ,code:"int16"
            ,value:"int32"
        }
        ,keyEvent:"event"
    }
    parser = new jParser(data,event)
    event1 = parser.parse("event");
    event2 = parser.parse("event");
    #console.log "event1 type:",event1.type,"code:",event1.code
    #console.log "event2 type:",event2.type,"code:",event2.code,"value:",event2.value
    module.exports.emit("key",event2.type,event2.code,event2.value)

每一次键盘时间都是一个72字节的数据块.分别由三个 input_event结构构成

struct input_event {
    struct timeval time;
    __u16 type;
    __u16 code;
    __s32 value;
};

经过我多次瞎猜,第一个和第三个input_event基本上都是些没用的信息,直接用第2个event就好了. type,code,value对应值的含义可以在/usr/include/linux/input.h 找到.

我选择使用 ScrollLock/Pause 这两个我从来没用过的键用作左右键. 他们分别对应着

#define KEY_SCROLLLOCK		70
#define KEY_PAUSE		119

总结

虽然还有优化的空间, 比如加入防抖动阈值或者增大factor, 但是目前实际使用已经足够了. LeapMotion在Linux下还是有稳定性问题的, 经常需要重启leapd. 在连续使用情况下,设备会非常烫手.

Leap Motion是非常科幻的设备, 但是实际使用起来还是很好上手的, 建议大家有机会还是入手一个.

源代码在Github