☰
Basalt手部关键点检测主流程拆解:两阶段设计与坐标变换要点
2026/10/21 10:07:34 网站建设 项目流程

最近把 Basalt 的完整主流程重新过了一遍,从输入图像到输出 21 个手部关键点,一路拆到坐标变换和模型部署,越看越觉得这套设计非常适合作为手势交互方案的底子。Basalt 的定位很简单:一个轻量的手部关键点检测与姿态估计方案,输入普通 RGB 帧,输出 21 个关键点坐标,全程保持移动端实时可运行。它把任务拆成手掌检测加关键点回归两个阶段,而不是直接端到端出关键点,这个选择直接决定了整个项目的训练难度、模型体积和运行时性能。如果你正准备做手势控制、快速原型验证,或者只想给现有 App 加一个不拖后腿的手部追踪能力,这篇主流程梳理可以帮你把管线里的每个环节都看清楚,也把最容易翻车的坐标变换地方提前标出来。

1. 先明确 Basalt 要解决什么:两阶段设计的动机

1.1 为什么不在原图上直接回归关键点

直接在一整张原始图像上做手部关键点回归,理论上可行,实际落地时却会迎面撞上一个非常现实的问题:手在画面中的尺度变化太大了。离镜头近的时候,一只手掌可能占掉半幅画面;离镜头远的时候,可能只有几十个像素宽。同一个模型如果既要处理极端近距离又要处理远距离,为了保持精度就不得不把输入分辨率调大,或者把网络做深,最终计算量会迅速膨胀,移动端根本扛不住。Basalt 的做法正好反过来:先用一个轻量检测器拿到手在哪,然后把检测区域裁剪缩放成固定尺寸,再让第二个模型只负责在这个归一化区域里找关键点。关键点回归器看到的输入分布非常稳定,网络可以收缩到很小的体积,精度和速度都能兼顾。

我在对比单阶段方案的时候还发现一个额外的好处,就是把任务拆开之后,调试路径也变短了。如果关键点不准,先看检测框准不准;如果检测框是准的,那问题就落在对齐和回归这一侧。每一步都能单独验证,不会出现一个巨大网络里哪个环节出了错都只能靠猜的情况。这其实是很朴素但对工程非常重要的一点。

1.2 检测器和回归器的分工边界

检测器本质上是一个基于 anchor 的轻量目标检测网络,输入完整图像,输出若干手部候选框。每个候选框除了置信度之外,还会有一组框回归参数,用来把 anchor 修正到真实的手掌位置。有的实现里还会顺带输出几个手掌参考关键点,比如手腕、食指根部、小指根部。这些参考点不是最终姿态的一部分,而是给后面的对齐步骤用的,相当于给矩形框补充了一个朝向信息。

回归器的任务则要纯粹得多:吃进固定尺寸的裁剪图,输出 21 个关键点的坐标。它不需要理解背景、不需要知道手在哪,视野里只有一只被归一化好的手。有了这个分工,两个模型都可以做得小而且好训练,互相之间的耦合也非常有限。后续如果想把回归精度做得更高,只需要单独替换回归分支,检测器完全不用动。

1.3 追求可落地性而不是精度上限

Basalt 并没有去争手部姿态估计的精度榜,它要的是在主流手机上能实时跑起来、模型体积小、接入成本低。21 个关键点对于很多交互场景来说已经足够:指尖能给出点击位置,手掌中心和手指根部可以给出整体朝向,手腕位置可以关联到手臂动作。如果要做更高精度的东西,比如手部 mesh 重建或者指尖皮肤级别的高精度追踪,那两阶段手部关键点方案本来就不该是首选。明确了自己的边界之后,再看主流程里的每一个设计取舍就会非常顺:为什么检测器轻量、为什么用固定尺寸裁剪、为什么坐标回归就够了,答案都是同一个——在实时性和可用性之间找平衡。

2. 主流程逐段拆解:从一帧图像到 21 个关键点

2.1 手掌检测器:定位手在哪

主流程的第一步是整图检测。输入帧会先做等比缩放,把长边压到模型期望的尺寸,同时记录缩放比例,因为后续检测框要映射回原图。检测器在缩放过后的图上跑一遍,输出一批带置信度的候选框,经过得分阈值过滤和 NMS 之后,通常只保留最高分的一个,作为这一帧的主手。这里有一个值得注意的细节:Basalt 检测的是手掌而不是整个手,因为手指形态变化太大,用矩形去包络整只手很难得到稳定的比例,而手掌区域相对规整,框出来之后更容易对齐。检测器这一步的输出质量直接决定整条链路的稳定性,所以阈值和 NMS 参数值得单独调一调。得分阈值设太高容易出现漏检,设太低又会被背景里的类手物体干扰,我习惯以漏检率优先,再用后处理去排除误检。

阶段典型配置说明
输入尺寸长边 256 或 192越小越快,漏检率升高
置信度阈值0.5~0.6太低误检多,太高漏检多
NMS IoU 阈值0.3~0.5决定候选框去重力度

这些参数没有绝对标准,要根据实际应用场景和设备算力去调。我用过 0.55 的置信度阈值配合 0.4 的 NMS 阈值,在普通室内环境下表现比较稳,但换到复杂背景里误检就会上升,需要在真机录制的数据上重新标定。

2.2 仿射对齐:把手部区域视图归一化

检测器给出来的是一个轴对齐矩形框,但手在画面里很可能是歪的、翻转的。如果直接把框出来的图喂给关键点回归器,回归器就得额外学会应对各种旋转角度,这对小网络来说是一个不必要的负担。Basalt 在检测框的基础上会再做一次仿射变换:利用检测器顺带输出的手掌参考关键点,计算手的朝向角度,然后围绕手掌中心把检测区域旋转、缩放到固定尺寸,常见选择是 192x192。这一步相当于视图归一化,把不同角度的手统一成一个相对规整的姿态,回归器看到的输入分布就稳定很多。

做对齐的时候,旋转中心、旋转角度、缩放因子三个量是耦合的。缩放因子由检测框尺寸和目标尺寸共同决定,旋转角度来自参考关键点连线的朝向。如果检测器没有输出参考关键点,也可以用中等手指根部到手腕的连线代替,或者干脆假设手掌已经朝上,只做缩放不做旋转。后一种实现简单,但在手部倾斜明显的场景下,回归精度会明显下降。

2.3 关键点回归器:从裁剪图出 21 点

裁剪图进入关键点回归网络,这是整个主流程里计算最集中的一步。网络结构一般是残差块堆叠的轻量 CNN。输出部分有两种常见设计:一种是直接回归 42 个数值,对应 21 个点的 x、y 坐标;另一种是输出 21 张热力图,推理时每张图找峰值。Basalt 这种轻量实时方案更适合直接坐标回归,因为热力图后处理要额外做峰值查找和亚像素定位,链路变长、计算量增加,收益却很有限。

输出坐标一般归一化到 [0,1],这样便于在不同分辨率之间换算。21 个关键点的编号规则是:0 号是手腕,然后从拇指到小指每根手指根部到指尖各 4 个点。我把编号和位置整理成一个表格,方便对照检查:

编号区间位置说明
0手腕整只手的关键点原点
1-4拇指从腕侧指根到指尖
5-8食指从食指根部到指尖
9-12中指从中指根部到指尖
13-16无名指从无名指根部到指尖
17-20小指从小指根部到指尖

这个顺序是当前手部关键点方案里比较通用的约定,Basalt 也沿用了它。做手势分类或者指尖点击的时候,直接按编号取点即可,不需要再读坐标向量里的位置关系。

2.4 逆仿射映射:坐标回到原图

回归器输出的关键点位于裁剪图坐标系,要用于原图渲染或者交互,必须做一次逆仿射变换。也就是把裁剪图坐标乘上仿射变换矩阵的逆矩阵,映射回原图坐标。这一步看起来只是几个矩阵乘除,实际上却是整个主流程中出错率最高的地方。许多人会图省事,用简单的比例缩放代替逆变换,结果旋转信息被丢掉,关键点位置在手的朝向明显时整体偏移。正确做法是保留构建裁剪图时的完整仿射矩阵,到这一步再求逆。如果构建裁剪图时用的是 OpenCV 的 warpAffine,求得逆矩阵后同样用矩阵乘法映射坐标,注意坐标先用齐次形式表示,再补上矩阵的多余一行。

3. 坐标变换链路:容易翻车的位置

3.1 同一帧图像里的三个坐标系

整条主流程里面至少存在三个坐标系:原始图像坐标系、检测框所在的原图像素坐标系、以及裁剪图坐标系。检测器输出的是原图像素坐标下的框,回归器输出的是裁剪图坐标系下的归一化坐标,渲染时又要把坐标放回原始图像坐标系。三套坐标之间的换算关系一旦错位,关键点就会直接飞掉。最常见的错误是把裁剪图上的关键点直接除以裁剪尺寸,当作原图归一化坐标来用。如果手刚好在画面中心、没有旋转,这个近似勉强成立;一旦手偏到画面边缘或者有旋转,误差会被放大到完全不可用,而且这种错误在可视化时表现得非常诡异,看起来像是模型完全不知道手在哪个方向。

3.2 仿射矩阵的构造与逆变换

构造从原图到裁剪图的仿射矩阵,最稳妥的方式是取原图中三个不共线的点作为对应点。比如说,取手掌检测矩形的左上角、右上角、左下角三个点,对应的目标坐标分别是裁剪图的左上角、右上角、左下角,然后调用仿射变换求解接口得到矩阵 M。运行时用 M 对原图做采样,得到裁剪图;推理结束后,把裁剪图上的关键点乘上 M 的逆矩阵,回到原图坐标。

求逆的时候要先把 M 这个 2x3 矩阵补成 3x3 齐次矩阵,也就是在最后补一行 [0, 0, 1],然后求逆,再把结果截成 2x3。这样得到的就是从裁剪图回到原图的 2x3 变换矩阵。整个过程如果用矩阵库来做就非常短,但很多人会在这里把坐标排列顺序搞混,导致结果绕着原点转了一圈。我的建议是写一个独立的小工具函数,把正向和逆向变换封装在同一处,所有模块统一调用,不要在多个地方重复实现。

3.3 两个真实踩坑案例

第一个坑是采样越界。检测框如果落在图像边缘,旋转后的裁剪区域有一部分会跑到图外。默认填充方式会把边界外的像素填成一个固定值,如果这个固定值跟训练数据里填充方式不一致,回归器在边缘场景就会输出明显异常。排查的时候如果不把裁剪图可视化出来,很难想到问题出在预处理这一层。第二个坑是坐标精度问题。检测框坐标如果在某一环被取整成了整数,经过旋转、缩放、逆变换之后,误差会被逐步放大,最终关键点可能偏移几个像素。手部交互场景里指尖就是点击的热区,几个像素足以点错位置。所以坐标变换链路的每一步都尽量保持 float 精度,只在最后一帧渲染时取整。

3.4 一张代码骨架帮你厘清变换

我用 Python 写了一个非常简化的代码骨架,用来展示正向仿射、采样、坐标回投三个动作:

import cv2 import numpy as np def build_affine(src_points, dst_size): # src_points: 原图中的三个点,例如检测矩形的三个角 dst_points = np.float32([[0, 0], [dst_size - 1, 0], [0, dst_size - 1]]) M = cv2.getAffineTransform(src_points, dst_points) return M def normalize_crop(image, M, dst_size): return cv2.warpAffine(image, M, (dst_size, dst_size)) def project_to_original(points_crop, M): M_h = np.vstack([M, [0, 0, 1]]) M_inv = np.linalg.inv(M_h)[:2, :] ones = np.ones((points_crop.shape[0], 1), dtype=np.float32) pts_h = np.concatenate([points_crop, ones], axis=1) return pts_h @ M_inv.T

代码本身不复杂,关键是架构上要保证 project_to_original 使用的 M 和 normalize_crop 使用的是同一个矩阵,而不是重新算一遍。一旦两个环节的矩阵不一致,坐标就一定会错位。

4. 模型训练、蒸馏与移动端部署落地

4.1 训练数据从哪来

关键点回归器的训练数据形式是裁剪图加 21 个关键点标注。最直接的做法是从公开的手部姿态数据集里取帧,用和主流程完全相同的仿射变换逻辑,把原图裁剪成 192x192,同时把标注点也变换到裁剪图坐标系。这个预处理必须和运行时保持一致,否则训练时模型看到的数据分布和部署时不一样,精度会垮得毫无头绪。如果没有现成标注数据,一个很省力的路径是请一个精度更高的模型当老师,对手部图像做推理生成伪标注,再用伪标注训练轻量模型,也就是蒸馏。关键是老师模型的质量要稳,最好在目标设备覆盖的场景里抽样验证过伪标注的可靠性。

数据增强对这个小回归网络非常重要。随机旋转、缩放、平移、颜色扰动都能涨点。水平翻转要特别注意:翻转之后左右手会互换,每根手指的标签位置需要重新映射,比如原本的右手小指会变成左手小指,不能简单地把所有坐标取反。如果这里处理不对,模型会在左右手混淆的状态下收敛,推理结果偶发镜像。

4.2 关键点标签编解码和损失函数

直接回归方案下,标签就是归一化到 [0,1] 的坐标,网络输出 42 个数值,损失函数用 Smooth L1 比较合适。Smooth L1 在误差较小时退化为 L2,较大时保持线性,对离群点不那么敏感,移动端量化之后误差变大的情况下,损失曲线也不容易震荡。如果走热力图方案,每个关键点对应一张低分辨率高斯峰值图,标签由坐标点生成,推理时在峰值图上做 argmax 再配合亚像素定位,精度通常更高一点,但输出通道更多、后处理更重。Basalt 这类场景里,热力图带来的精度增益比不过它增加的计算和延迟,直接回归是合理的选择。

还有一个值得考虑的点是可见性。手指被遮挡时标注其实是不确定的,如果模型强行回归一个位置,往往会把手指移到奇怪的地方。如果目标场景里遮挡常见,可以在输出向量里增加一个可见性分支,或者在损失函数里忽略被遮挡的点的梯度。这一步对最终交互体验的影响比想象中大。

4.3 TFLite 转换与量化避坑

模型训练完要转到移动端推理格式,TensorFlow 生态这条路最常用的是 TFLite。转换流程本身很快,但量化这里值得多花时间。全整型量化会把模型的所有权重和激活压到 8 bit,关键点坐标输出也要经过反量化。实际跑下来,最明显的精度损失发生在小指指尖这类小幅运动点上,因为它们的坐标差异本来就小,量化误差一进来就被淹没。如果项目对指尖精度敏感,可以只对最后一个卷积层或者输出层保留浮点,或者干脆做量化感知训练,让模型在量化噪声下重新学习。转换完之后,务必检查输入输出 Tensor 的 scale 和 zero_point,运行时代码里的归一化方式要和转换前一致,最常见的偏差是训练时代码用 float 输入,部署时却忘了缩放 [0,1] 的数据,导致模型看到完全不同的数值范围。

4.4 推理性能从哪省

性能优化可以从两个模型分别入手。关键点回归模型输入固定是 192x192 的小图,移动端 CPU 上一次推理通常能跑在十几毫秒到几十毫秒。检测器跑在整图上,耗时波动更大,才是通常的瓶颈。如果帧率不够,第一步把检测器输入分辨率降下来,从 256 降到 192,或者干脆隔帧检测,手部运动相对平滑,关键点回归器仍可每帧执行,不会产生太明显的跳变。有条件的话用 GPU delegate 或硬件 NPU 可以进一步降推理耗时,但会引入初始化延迟,适合长时间运行的交互场景,不适合频繁启动的轻量工具。同一帧内,两个模型共用一个解释器实例也比反复加载模型省不少开销。

5. 实测中的常见问题与调优心得

5.1 关键点抖动的根因和修复

跑起来之后最影响观感的是关键点抖动。刚开始我以为是回归模型不够稳,后来把裁剪图可视化出来才发现,问题往往出在检测器输出的检测框上。相邻两帧检测框如果有三五个像素的波动,裁剪区域就跟着抖,回归器输出的关键点自然也在抖,逆仿射回原图之后抖动幅度还会被放大。修复思路是给检测框和关键点分别加平滑。检测框用指数移动平均或者卡尔曼滤波都能有效稳住;关键点层面再做一次轻量平滑。但平滑系数不是越大越好,过度平滑会让手部动作看起来黏滞,交互延迟也会上升。我一般把端到端延迟控制在 50 毫秒以内,在平滑和不跟手之间找一个可接受的折中点。

如果要用指数移动平均,可以按这个公式来:p_t = alpha * p_t + (1 - alpha) * p_{t-1},alpha 取 0.4 到 0.7 之间。alpha 偏大平滑力度弱、跟手性好,偏小平滑力度强、但会有明显残留轨迹。具体值要放到真机上看实际效果,不要只看理论曲线。

5.2 多手和遮挡场景怎么兜底

Basalt 主流程默认只处理得分最高的那只手,这是单手交互场景下的合理选择。如果产品需要多手同时追踪,NMS 阶段改成保留前 K 个候选框,后面的对齐、回归、逆映射流程对每个框各跑一遍就行。真正麻烦的是两只手重叠时,回归器容易把手指关键点跳到另一只手上。目前最务实的兜底是给回归器增加可见性分支,让模型同时输出每个关键点是否可见。后处理再结合手部面积、位置连续性做一些跟踪匹配,能挡住大部分误判。要注意的是多手场景下的计算量会成倍增长,需要配合降低检测频率或者缩小输入分辨率来保帧率。

5.3 性能瓶颈分析与优化

用性能分析工具跑一遍主流程,绝大多数情况下瓶颈在整图检测器。优化优先级可以按这个顺序排:先降检测器输入分辨率,再降检测频率,接着减少 NMS 候选数量。回归器这边,192x192 的输入如果还嫌重,尝试 160 或 128,精度损失主要集中在手指根部和远端小指,但很多交互场景可接受。还有两个容易被忽略的优化点:一是两段模型共用一个推理解释器实例,二是线程数配置要和设备核心数匹配。很多时候帧率不够不是模型本身算不动,而是线程没设好、模型反复加载、后处理里做了太多不必要的数组拷贝。把这些清理掉,常常比换模型结构更有效。

5.4 主流程之外的扩展空间

Basalt 的主流程是一个很好用的底座。拿到 21 点之后,最简单的是接静态手势分类,把关键点归一化后过一个小分类头;想要点击语义可以用指尖位置配合深度或者平面判定;想识别动态手势,可以把连续帧的关键点序列喂给时序模型。AR 场景里,21 点可以充当交互锚点,指尖稳定后叠加特效完全没有问题。未来如果要做 3D 手部姿态,也不需要推翻主流程,在 2D 关键点后面接一个 3D 恢复头就可以了。整个架构的替换点和扩展点都很清楚,这也是我为什么愿意把主流程从头到尾梳理一遍:管线搭顺了,后面加什么都快。

我自己在落地这一套流程之后最大的感受是,Basalt 真正值钱的地方不是某个网络结构有多精巧,而是它的主流程把检测、对齐、回归、映射、部署这几件事安排得很干净。如果要给后来者一句建议,我会说先把坐标变换链路和预处理细节验证扎实,再去做模型调优,因为绝大多数看起来是模型精度不足的问题,最后查出来其实出在变换或归一化上。手部姿态估计这个方向更新得很快,但主流程的基本功永远不过时。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询