visionOS 手势匹配框架更新至 2.0,新增手指形状参数
HandVector
Vision Pro 发布后,优雅的手势交互引发了很大热度,但遗憾的是苹果并没有提供一个 3D 手势识别的功能,连静态的都没有。于是我使用 余弦相似度算法 自己编写了 HandVector 框架来实现这个功能:比如,用手比出✌️手势触发某种效果,还写了文章介绍 《我开源了个手势匹配框架,让你在模拟器调试 visionOS 手部追踪功能!》
HandVector 除了能计算不同静态手势之间的相似度外,并且带有一个 macOS 的工具类能让你在 visionOS 模拟器上也能使用手势追踪功能进行基础的手势测试。
本次 HandVector 2.0 版本是一个大更新,带来更好的 余弦相似度 Cosine Similarity 匹配效果和 手指形状参数 FingerShape 功能,更方便自定义使用。
注意:HandVector 2.0 有重大 API 改动,与旧版本不兼容。
余弦相似度计算更新
也许大家会纳闷:余弦相似度算法还能怎么改进?确实,算法本身难以改进,但我改进了计算时的参照坐标系,从而带来了整体的巨大改进。
我们先来看看旧版本的算法最大问题就是:所有计算用到的向量,都是基于 手部 Anchor 空间 (手腕)的,这样就会导致手势变化过程中,指尖的关节 方向差异过大,相似度计算出现错误:
比如上图中,两个手势一个手指半弯曲,一个手指完全弯曲,它们明明具有一定相似性,但相对于坐标原点来说,指尖向量朝向却几乎垂直,造成的结果就是:它们的余弦相似度结果接近于 0,这显然是不合理的。
于是,在 2.0 版本中,我对此进行了改进,采用 父关节点的矩阵空间,来计算向量方向,这样 指尖向量 的相似度就大大提高了。
更新之后,上图两个手势的指尖姿态,就高度相似了。但这样又带来了新的问题:坐标转换需要求逆矩阵,会不会有性能影响?答案是不会的,因为 ARKit 提供了这个矩阵,如下图:
parentFromJointTransform
其实就是当前关节点相对于父级关节的姿态矩阵,而 anchorFromJointTransform
是相对于 Anchor (手腕)的姿态矩阵。我们可以直接保存使用,无需额外计算消耗。
命名公式:A From B Transform = B Relative to A transform parentFromJointTransform = Joint Relative to Parent transform anchorFromJointTransform = Joint Relative to Anchor transform
新增 FingerShape
采用余弦相似度算法准确度较高,但其中的参数难以理解,也几乎无法调整。为此,我参考 Unity 的 XRHands 框架,新增了 FingerShape 功能。
它将手指形状化简为卷曲度与分离度等 5 个参数,这些数值方便理解,也方便控制与调整:
- 指根卷曲度 baseCurl:手指根部关节的卷曲度,大拇指为
IntermediateBase
关节,其余手指为Knuckle
关节,范围 0~1
- 指尖卷曲度 tipCurl:手指上部关节的卷曲度,大拇指为
IntermediateTip
关节,其余手指为IntermediateBase
和IntermediateTip
两个关节的平均值,范围 0~1
- 整体卷曲度 fullCurl:baseCurl 与 tipCurl 的平均值,范围 0~1
- (与拇指)捏合度 pinch:计算与拇指指尖的距离,范围 0~1,拇指该参数为
nil
- (与外侧相邻手指)分离度 spread:只计算水平方向上的夹角,范围 0~1,小拇指该参数为
nil
关于三个不同的卷曲度有什么区别,可以参考下图:
SimHand 更新
模拟器调试功能一直是 HandVector 的一大亮点功能,但是 2.0 更新变更了向量坐标系后,带来了巨大的麻烦:Google 的 MediaPipes 实现的 3D 手势追踪,只有关节点的位置,没有矩阵信息!!!
这意味着我需要使用大量的数学 “黑魔法” 来无中生有相关的矩阵信息!这些复杂代码足足耗费了近一周时间编写与调试,中途几度准备放弃……
// 令人头秃的“黑魔法”运算代码
private static func calculateJointTransform(jointDict: [HandSkeleton.JointName: Joint], rootTransform: simd_float4x4, isLeft: Bool) -> [HandSkeleton.JointName: simd_float4x4] {
var worldTransforms: [HandSkeleton.JointName: simd_float4x4] = [:]
// thumb transform need refer palm center
let knukleCenter = (jointDict[.indexFingerKnuckle]!.position + jointDict[.middleFingerKnuckle]!.position + jointDict[.ringFingerKnuckle]!.position + jointDict[.littleFingerKnuckle]!.position) / 5.0
let palmCenter = knukleCenter + jointDict[.wrist]!.position / 5.0
let palmOffset = palmCenter + rootTransform.columns.1.xyz * (isLeft ? 0.035 : -0.035)
// thumbIntermediateBase
let thumbInterBaseX = normalize(jointDict[.thumbIntermediateTip]!.position - jointDict[.thumbIntermediateBase]!.position) * (isLeft ? 1 : -1)
let thumbInterBaseY = normalize(palmOffset - jointDict[.thumbIntermediateBase]!.position) * (isLeft ? 1 : -1)
let thumbInterBaseZ = cross(thumbInterBaseX, thumbInterBaseY)
worldTransforms[.thumbIntermediateBase] = simd_float4x4(SIMD4(thumbInterBaseX, 0), SIMD4(thumbInterBaseY, 0), SIMD4(thumbInterBaseZ, 0), SIMD4(jointDict[.thumbIntermediateBase]!.position, 1))
......
let rootZ = rootTransform.columns.2.xyz
// indexFingerMetacarpal
let indexMetaX = normalize(jointDict[.indexFingerKnuckle]!.position - jointDict[.indexFingerMetacarpal]!.position) * (isLeft ? 1 : -1)
let indexMetaY = cross(rootZ, indexMetaX)
let indexMetaZ = cross(indexMetaX, indexMetaY)
worldTransforms[.indexFingerMetacarpal] = simd_float4x4(SIMD4(indexMetaX, 0), SIMD4(indexMetaY, 0), SIMD4(indexMetaZ, 0), SIMD4(jointDict[.indexFingerMetacarpal]!.position, 1))
......
}
好在凭借我优秀的几何知识,成功用代码从位置复现了矩阵,虽然不够精确但勉强可支持调试使用,HandVector 的一大特色得以保留:
其他
除了以上三大功能,我还对项目结构和 3D 关节样式进行了调整,代码结构更清晰。
关节可视化方面,从小球体变为了小立方体,更方便展示不同朝向的区别:XYZ 轴正方向颜色分别为 RGB,负方向则为暗色 RGB。
最后,2.0 版本仍在 Beta 测试中,欢迎使用并提交反馈。