1. 手势点灯走到第四步:为什么这次不谈"认手势",只谈"量手指"
1.1 从"认整只手"到"量每根手指"的路线切换
手势点灯这个系列做到第四步,思路其实已经换过一次血了。前三个任务里我们干的活儿,本质上是"给整只手贴标签"——把一整只手的姿态当成一个整体样本,去和预存的模板比对,或者丢进一个轻量分类器里训练。这套路子在小样本、固定场景下跑得挺好,但只要换个人、换个角度、换盏灯,之前标好的模板就集体失效。任务四要做的"手势识别数字"如果还按模板匹配的老路走,光是 0 到 5 六个数字把两只手都算上,工作量和维护成本就足以把人劝退。
所以我在这里做了一次比较彻底的方案切换:不再把整只手当作黑盒去匹配,而是先把手拆成 21 个关键点,再对每一根手指单独测量它的弯曲程度,最后用一个很朴素的计数器把"伸出了几根"数出来。这个数字就是我们要的结果,直接送出去点灯。关键词里的"手势识别数字"和"手指弯曲程度",说的就是这条链路——手指弯曲程度是特征,手势识别数字是结论,中间没有任何模型训练,全是几何量。
这条路子的好处非常实在:不需要采集数据集,不需要训练,不需要担心新用户进来模型不认识,参数改两行就能适配新手型。代价也很明显——你得真正理解每一根手指的关节是怎么运动的,以及为什么大拇指不能和前四根手指用同一套规则。后面几节我会把这两件事掰开了讲。
1.2 这个任务到底解决了什么,谁适合照着做
先说清楚它解决什么问题。在很多交互式小项目里,我们并不需要一个"全能手势识别器",我们只需要一个稳定的、可解释的、能实时反馈的数字输入通道。比如展台上的互动装置,观众比出几根手指,装置就点亮对应数量的灯带;比如教学演示台,老师比 3 就亮三颗灯做示范;再比如无障碍方向的小玩具,用最自然的手部动作代替按键。
这类场景有几个共同点:对准确率的要求不是 99%,但对稳定性要求极高;响应延迟必须肉眼无感;出了错必须能快速定位是哪一步算歪了。深度学习端到端方案在这三条上恰恰都不占优势——你很难向别人解释"为什么这一帧它认错了"。而基于关节角度的几何方法,每一帧都能打印出一串角度值,出问题时你一眼就知道是阈值没设对,还是手指被遮挡了。
适合读这篇的人也很明确:已经能把摄像头画面读进来、能画出关键点骨架的初中级开发者,正在做手势相关的课程设计或者个人小创作,希望把"识别结果"变成"物理动作"的玩家。如果你连 21 个关键点的编号都还没搞清楚,也没关系,第 3 节我会把每个编号对应的关节位置全部列出来,照着对一遍就能上手。
2. 整体方案拆解:21 个关键点、5 个角度、1 个数字
2.1 四段式数据流:采集、度量、判决、执行
整套系统我拆成四段,段与段之间的接口非常干净,这也是我特别推荐新手按这个方式组织代码的原因——任何一段出问题,都能单独替换掉而不影响其他三段。
第一段是采集与关键点提取。摄像头给一帧 BGR 图像,手部关键点模型输出 21 个点的归一化坐标(x、y 在 0 到 1 之间,z 是相对深度)。这一段是最"黑盒"的部分,我们不做任何改动,只负责把结果取出来。
第二段是度量。把 21 个点按语义分组,每根手指取三个关键点算一个关节夹角,再补一个"指尖到手腕的距离比掌宽"作为辅助量。这一段全是纯几何运算,输入是坐标,输出是 5 组(角度,距离比)。
第三段是判决。给每组度量配上阈值,判定这根手指"伸"还是"屈",五个布尔值加起来就是数字。判决层还要负责去抖和迟滞,否则数字会在相邻两帧之间反复横跳,灯就会闪成霓虹灯。
第四段是执行。把确认后的数字通过串口(或者直接控制 GPIO、或者驱动屏幕上的虚拟灯)下发出去。这一段的关键是"只在数字变化时发",不然串口会被刷爆。
我之所以强调这个四段式,是因为大多数新手写这类代码时会把这四件事全塞进while循环的一个大函数里,结果调试的时候根本不知道问题出在识别还是出在通信。分层之后,你可以把第二段的输入换成一段录好的坐标序列离线跑,快速调阈值,不用一直对着摄像头比手。
2.2 三种常见判据的横向对比,以及我为什么选角度
在动手之前,判断"手指是不是伸直"其实有三条主流路子,我把它们的实际表现列出来,方便你判断自己该走哪条。
| 判据 | 具体做法 | 优点 | 实际踩到的坑 |
|---|---|---|---|
| 坐标比较法 | 比较指尖 y 坐标与指根 y 坐标的高低 | 实现最简单,两三行代码 | 手一倾斜就全废,手掌朝侧面时彻底失效 |
| 距离比值法 | 指尖到手腕距离 ÷ 掌宽 | 与手的远近无关,比较稳 | 手指半弯时距离变化不明显,灰区很宽 |
| 关节角度法 | 用指根、中间关节、指尖三点算夹角 | 直接对应"弯曲"这个物理量,直观可解释 | 需要处理坐标系与遮挡,拇指要单独调 |
我最终选的是关节角度法为主、距离比值法为辅的组合。理由很直接:标题里的关键词就是"手指弯曲程度",而弯曲程度在几何上最贴切的度量就是这个夹角。手指伸直时,指根—中间关节—指尖三点几乎共线,夹角接近 180 度;手指弯曲时,中间关节凸起,夹角迅速掉到 100 度以下。这个变化幅度足够大,阈值留出的安全区很宽。
距离比值法在这里的作用是"投票兜底"。有些帧里手指被另一只手挡住,或者手掌正好侧对镜头导致关节角被压扁,这时候角度会失真,但指尖到手腕的距离比往往还是对的。两个判据一起投票,误判率会明显下降。这个组合我实测下来,在普通室内光照、手距离摄像头 40 到 80 厘米的区间内,单手数字识别基本可以做到连续十分钟不误跳。
2.3 灯的映射逻辑:别让"0"成为一个尴尬的数字
数字算出来了,怎么变成灯,这里有个小设计问题很多人会忽略:0 根手指亮几盏灯?从逻辑上讲 0 就该全灭,但实际体验会很差——用户比出拳头,灯全灭了,他会以为设备坏了或者断连了。
我在实物上试了三种映射,最后用的是"0 表示全灭,但同时让一颗状态指示灯常亮"的方案:五颗主灯对应 1 到 5,另有一颗独立的状态灯表示"系统在线且已检测到手"。这样 0 的时候主灯全灭但状态灯亮着,用户马上能理解"它看见我了,只是我比了个 0"。
另外还有两个细节值得说。一是过渡方式:数字从 2 跳到 5 的时候,是一次性五颗全亮,还是逐颗点亮?我倾向逐颗点亮,间隔 60 毫秒左右,观感上像"数数",而且能掩盖去抖带来的那一点点延迟。二是编号顺序:灯从左到右排列,一定要和手指从左到右的顺序在视觉上对应上,否则用户会在脑子里做一次映射,体验会变得很别扭。这属于典型的"功能都对,就是用着不舒服"的问题,只有真正上手比划过才知道。
如果你想把范围扩到 6 到 9,最省事的做法是双手组合:左手当十位、右手当个位,约定左手只能比 0 或 1,这样 0 到 19 全都能表达。再想省事一点,可以让"两只手都伸出"作为一种模式切换手势,进入模式后再用单手比 6 到 9 的特殊指型。不过我个人的建议是:先把单手 0 到 5 做到极其稳,再去想扩展,因为双手识别会引入左右手判定、两只手互相遮挡、画面不够宽等一堆新问题。
3. 核心细节:手指弯曲程度到底怎么量才稳
3.1 关键点编号对照表与三点夹角的构造
21 个关键点的编号是固定语义的,这是所有后续计算的基础,我把它整理成表,建议你打印出来贴在屏幕边上。
| 编号 | 语义位置 | 编号 | 语义位置 |
|---|---|---|---|
| 0 | 手腕根部 | 11 | 中指第二关节 |
| 1 | 拇指根部 | 12 | 中指指尖 |
| 2 | 拇指第一关节 | 13 | 无名指根部 |
| 3 | 拇指第二关节 | 14 | 无名指第一关节 |
| 4 | 拇指指尖 | 15 | 无名指第二关节 |
| 5 | 食指根部 | 16 | 无名指指尖 |
| 6 | 食指第一关节 | 17 | 小指根部 |
| 7 | 食指第二关节 | 18 | 小指第一关节 |
| 8 | 食指指尖 | 19 | 小指第二关节 |
| 9 | 中指根部 | 20 | 小指指尖 |
| 10 | 中指第一关节 |
四根长指的判定统一用"根部—第一关节—第二关节"这三个点,也就是食指用 5、6、7,中指用 9、10、11,无名指用 13、14、15,小指用 17、18、19。注意这里我用的是第二关节(DIP)而不是指尖,原因是指尖点在有遮挡或者快速运动时最容易抖,而 DIP 相对稳定;同时 DIP 处在弯曲时角度变化也足够大。如果你更在意"末节弯下去但中间没弯"这种特殊指型(比如比"OK"),那就把第三点换成指尖,代价是抗抖动能力下降一些。
夹角计算就是标准的向量点积:
import math import numpy as np def angle_at(a, b, c): """以 b 为顶点,计算向量 ba 与 bc 的夹角,返回角度制。""" ba = np.asarray(a, dtype=np.float64) - np.asarray(b, dtype=np.float64) bc = np.asarray(c, dtype=np.float64) - np.asarray(b, dtype=np.float64) denom = (np.linalg.norm(ba) * np.linalg.norm(bc)) + 1e-8 cosv = float(np.dot(ba, bc) / denom) cosv = max(-1.0, min(1.0, cosv)) # 防浮点误差越界 return math.degrees(math.acos(cosv))这里有个必须强调的点:只用 x、y 两个分量算,把 z 丢掉。我最初是带着 z 算三维夹角的,结果发现抖动严重得多。原因是深度值本身是模型估算出来的,误差比平面坐标大一个量级,把它塞进点积里等于把噪声放大了。除非你的手指正好完全侧对镜头(这种情况本身就该判为无效帧),否则二维夹角完全够用。这个改动让我的判定波动幅度直接小了一半。
3.2 尺度归一化:让阈值跟手的远近、大小彻底解耦
角度这个量本身是尺度无关的,手离得远、近、大、小,角度都不会变——这是角度法最大的优势。但距离比值法不是,所以必须做归一化。归一化的分母我选的是掌宽,也就是手腕点 0 到中指根部点 9 的欧氏距离。
为什么用掌宽而不是"画面高度"或者"掌心到指尖的最大距离"?因为掌宽是一个跟手指姿态几乎无关的量。你比拳头也好,五指张开也好,手腕到中指根部这段距离基本不变(中指根部长在掌骨末端,不会因为手指弯曲而移动)。用它作分母,得到的所有距离比值都自动排除了"手大手小""手近手远"的影响,阈值可以一套通用。
def palm_width(pts): """掌宽:手腕(0) 到 中指根部(9) 的二维距离,作为尺度基准。""" return float(np.linalg.norm(pts[0][:2] - pts[9][:2])) + 1e-8 def tip_ratio(pts, tip_idx): """指尖到手腕距离 / 掌宽。伸指时约 2.0~2.4,握拳时约 1.2~1.5。""" return float(np.linalg.norm(pts[tip_idx][:2] - pts[0][:2])) / palm_width(pts)上面注释里的数值范围是我在几种常见手型上量出来的参考值,它们不是定律,但可以作为你标定的起点。可以看到伸直和弯曲的区间几乎没有重叠,中间留了 1.5 到 2.0 这一段灰区,所以距离判据单独用其实也能跑,只是灰区里会犹豫。
3.3 拇指为什么必须单独处理,怎么处理
如果四根长指是"规规矩矩的铰链",那拇指就是个"球窝关节加一堆斜向肌肉"的麻烦角色。它有三个关节,运动方向斜向掌心,而且在不同人手上差异极大:有人拇指能反折很大角度,有人天生就是弯的。如果你直接用食指那套角度规则套在拇指上,会稳定地出现两类错误——握拳时拇指被判为伸出,或者拇指完全张开时被判为弯曲。
我的做法是给拇指配一套独立的规则,并且优先用位置关系而不是纯角度。具体是两步:
第一步,算拇指的 IP 关节角,用 1、2、4 三点(跨过 3 号点,减少单点噪声影响)。伸直时这个角通常大于 150 度,弯曲时掉到 110 度以下,阈值可以取在 130 度附近。
第二步,也是更关键的一步——算拇指尖 4 到食指根部 5 的距离,除以掌宽。这个比值反映的是"拇指是张开在手掌外侧,还是收拢贴在掌心"。拇指外张时这个比值明显偏大,收拢时偏小。用这个几何关系判断,比纯角度稳得多,因为它绕开了拇指关节角度的个体差异。
最终的判决我采用"角度和距离各投一票,两票一致才输出,不一致就维持上一帧状态"。这种不一致就保持的做法虽然会引入一帧的延迟,但换来的是彻底消除了拇指在临界点上反复横跳的问题。拇指是整套系统里最容易出错的部位,值得为它多写二十行。
3.4 灰区与迟滞:让判决从"跳变"变成"稳变"
前面几次提到"灰区",这里正式说一下。假设食指伸直时角度是 170 度、弯曲时是 80 度,你直接把阈值设在 125 度,理论上没问题。但实际手指在做动作的过程中会经过 125 度,而且会在附近停留几帧,这几帧里角度可能在 124 和 126 之间摆动,判定结果就会翻转。
解决办法是设两个阈值,形成迟滞区间。伸展阈值取 150 度,弯曲阈值取 110 度。当前状态是"伸"的时候,只有角度掉到 110 以下才切换到"屈";当前状态是"屈"的时候,只有角度升到 150 以上才切回"伸"。中间 40 度的区间里,状态保持不变。这样一来,手指半弯的中间态就不会引发任何翻转。
class Hysteresis: """单根手指的迟滞判决器。""" def __init__(self, open_th=150.0, close_th=110.0, init=False): self.open_th = open_th self.close_th = close_th self.state = init def update(self, angle): if self.state: # 当前判定为伸出 if angle < self.close_th: self.state = False else: # 当前判定为弯曲 if angle > self.open_th: self.state = True return self.state这个小东西看起来平平无奇,但它是我从"能用"到"好用"之间最关键的一步改动。加之前,灯光在小幅度手部晃动下会不停闪;加之后,同样的动作下几乎一动不动。数值上你完全可以把阈值带宽调窄到 20 度以内,响应会更快,代价是稳定性略降,这个平衡点建议自己试着找。
4. 实操:从摄像头画面跑通到灯亮起来
4.1 环境准备与依赖安装
整条链路跑在 Python 上,依赖就三个:图像处理库、手部关键点模型库、串口库。版本上我建议用比较新的稳定版,老版本的关键点模型在快速运动时抖动明显更大。
pip install opencv-python pip install mediapipe pip install pyserial装完之后先跑一个最小验证脚本,确认摄像头能开、能出关键点,再往下写业务逻辑。这一步千万别跳过,我见过太多次把"摄像头被别的程序占用"这类环境问题误判成算法问题了。
import cv2 import mediapipe as mp mp_hands = mp.solutions.hands cap = cv2.VideoCapture(0) cap.set(cv2.CAP_PROP_FRAME_WIDTH, 640) cap.set(cv2.CAP_PROP_FRAME_HEIGHT, 480) with mp_hands.Hands( static_image_mode=False, max_num_hands=1, model_complexity=0, # 0 最快,够用 min_detection_confidence=0.6, min_tracking_confidence=0.5) as hands: while True: ok, frame = cap.read() if not ok: break frame = cv2.flip(frame, 1) # 镜像,符合直觉 rgb = cv2.cvtColor(frame, cv2.COLOR_BGR2RGB) rgb.flags.writeable = False result = hands.process(rgb) if result.multi_hand_landmarks: lm = result.multi_hand_landmarks[0] h, w = frame.shape[:2] pts = [(p.x * w, p.y * h, p.z * w) for p in lm.landmark] # 后续所有计算都基于 pts cv2.imshow("hand", frame) if cv2.waitKey(1) & 0xFF == 27: break cap.release() cv2.destroyAllWindows()model_complexity=0是我强烈推荐的一个参数。它对应的模型更轻,在普通笔记本上单帧耗时能从二十几毫秒降到十毫秒以内,而我们这套方法对关键点的精度要求其实不高——你只要能分出 150 度和 110 度,精度再高也没额外收益。max_num_hands=1同理,限制只找一只手,能省掉一半的推理时间,也避免了"识别到两只手该听谁的"这种逻辑麻烦。
4.2 双判据判决器的完整实现
把前面的碎片组装成完整模块。注意我把"角度"和"距离比"都返回出来,方便调试时打印观察。
import math import numpy as np # 四根长指:根部、第一关节、第二关节 LONG_FINGERS = { "index": dict(triplet=(5, 6, 7), tip=8, mcp=5, open_th=150, close_th=110), "middle": dict(triplet=(9, 10, 11), tip=12, mcp=9, open_th=150, close_th=110), "ring": dict(triplet=(13, 14, 15), tip=16, mcp=13, open_th=148, close_th=108), "pinky": dict(triplet=(17, 18, 19), tip=20, mcp=17, open_th=145, close_th=105), } def angle_at(a, b, c): ba = np.asarray(a[:2]) - np.asarray(b[:2]) bc = np.asarray(c[:2]) - np.asarray(b[:2]) d = (np.linalg.norm(ba) * np.linalg.norm(bc)) + 1e-8 return math.degrees(math.acos(max(-1.0, min(1.0, float(np.dot(ba, bc) / d))))) def palm_width(pts): return float(np.linalg.norm(np.asarray(pts[0][:2]) - np.asarray(pts[9][:2]))) + 1e-8 def long_finger_state(pts, cfg, hyst): i0, i1, i2 = cfg["triplet"] ang = angle_at(pts[i0], pts[i1], pts[i2]) ratio = float(np.linalg.norm(np.asarray(pts[cfg["tip"]][:2]) - np.asarray(pts[0][:2]))) / palm_width(pts) st = hyst.update(ang) # 角度主导 if not st and ratio > 2.0: # 距离强力反驳 -> 判伸 st = True if st and ratio < 1.4: # 距离强力反驳 -> 判屈 st = False return st, ang, ratio这里有个取舍要说明:为什么角度主导而不是让两个判据平等投票?因为我在实测中发现,距离判据在"小指"上误判率明显高于角度判据——小指本来就短,弯曲时指尖到手腕的距离下降幅度不如其他手指明显,比值容易落在灰区。所以把它降级为"强力反驳"角色:只有比值极端(大于 2.0 或小于 1.4)时才推翻角度结论,正常范围内听角度的。这个设计比平均投票稳。
拇指单独实现:
def thumb_state(pts, hyst): # 角度:跨过 3 号点,用 1-2-4 ang = angle_at(pts[1], pts[2], pts[4]) # 距离:拇指尖到食指根部的距离 / 掌宽 d = float(np.linalg.norm(np.asarray(pts[4][:2]) - np.asarray(pts[5][:2]))) / palm_width(pts) vote_angle = hyst.update(ang) # 阈值 130 / 95 自行设定 vote_dist = d > 0.55 # 0.55 为外张判据参考值 if vote_angle == vote_dist: return vote_angle return hyst.state # 不一致则维持4.3 主循环、去抖状态机与数字输出
判决层拿到的是五个布尔值,直接求和就得到 0 到 5。但绝对不能把这个和直接送去点灯,必须过一层去抖。我用的是滑动窗口多数表决:维护一个长度 7 的队列,某个数字在队列里出现 5 次以上才被确认为当前数字。
from collections import deque class DigitStabilizer: def __init__(self, window=7, need=5): self.buf = deque(maxlen=window) self.need = need self.confirmed = None def push(self, digit): self.buf.append(digit) if len(self.buf) < self.buf.maxlen: return self.confirmed top = max(set(self.buf), key=self.buf.count) if self.buf.count(top) >= self.need: self.confirmed = top return self.confirmed窗口 7 帧、需要 5 票,这个配置在 25 到 30 帧的帧率下,意味着确认延迟大约 0.2 秒。这个数字我觉得是甜点:再短会抖,再长会让人觉得"手比完了灯才反应过来"。如果你的机器帧率只有 15 帧,务必把窗口缩到 5、票数 3,否则延迟会到 0.4 秒以上,用起来很难受。
主循环里还有个必须处理的细节:只在确认数字发生变化时才下发。
last_sent = None stab = DigitStabilizer(window=7, need=5) # ... 在循环中 ... raw = sum([thumb_ok, index_ok, middle_ok, ring_ok, pinky_ok]) confirmed = stab.push(raw) if confirmed is not None and confirmed != last_sent: last_sent = confirmed ser.write(f"{confirmed}\n".encode()) # 串口下发 print(f"[OUT] digit={confirmed}")confirmed is not None这一句很重要。窗口没填满的时候它返回 None,表示"还没结论",这时候绝对不能当成 0 下发出去了,否则每次启动灯都会先灭一下。这种启动时序问题在实际装置上非常显眼,属于必须提前想好的事。
4.4 下位机侧:把数字变成灯的数量
上位机只管发数字,下位机负责把数字翻译成几颗灯亮。我用一块常见的小开发板挂五颗 LED,代码很短但有两个细节值钱。
const uint8_t LED_PINS[5] = {2, 3, 4, 5, 6}; uint8_t current = 0; void applyLevel(uint8_t n) { if (n > 5) return; for (uint8_t i = 0; i < 5; i++) { digitalWrite(LED_PINS[i], (i < n) ? HIGH : LOW); if (i < n) delay(60); // 逐颗点亮的节奏,制造"数数"感 } } void setup() { Serial.begin(115200); for (uint8_t i = 0; i < 5; i++) { pinMode(LED_PINS[i], OUTPUT); digitalWrite(LED_PINS[i], LOW); } } void loop() { if (Serial.available() > 0) { int v = Serial.parseInt(); if (v >= 0 && v <= 5 && v != current) { current = (uint8_t)v; applyLevel(current); Serial.print("ACK "); Serial.println(current); } } }两个细节。第一,v != current这个是必须的,否则上位机重发同样数字时会重新跑一遍点亮动画,灯会莫名其妙地重播。第二,Serial.parseInt()会阻塞等待,默认超时是 1 秒,如果你的上位机可能不发数据,最好改成手动拼字符并设超时,或者干脆用固定长度协议加校验字节。我在一个需要长时间运行的装置上就因为这个问题出现过"手拿开之后灯卡住不动"的现象,排查了半天才发现是解析超时把主循环堵住了。
另外那个delay(60)虽然让观感变好,但它会阻塞五颗灯全亮的流程(最坏 300 毫秒)。如果你同时还在读传感器或者做别的实时任务,这个延时就得换成基于毫秒计时的非阻塞写法。
5. 参数标定:为什么默认阈值在你手上总差那么一点
5.1 做一个标定模式,比反复改代码高效十倍
我在第一个版本里干了一件很蠢的事:每换一个人测试,就手动改一遍源码里的阈值,重新运行。改到第四个人的时候我放弃了,写了个标定模式——按一个键进入,屏幕提示"把五指完全张开保持 3 秒",采集这 3 秒内每根手指角度的分布,取中位数作为该手指的"伸展基准";再提示"握紧拳头保持 3 秒",同样取中位数作为"弯曲基准"。然后两个阈值取基准之间按比例插值,比如伸展阈值取 基准伸 的 92%,弯曲阈值取 基准屈 的 115%。
这套流程跑下来,五根手指的阈值全部个性化,切换用户只要 8 秒。实测下来,标定后的误判率比用固定阈值低了不止一个数量级。原因是不同人的手型差异真的很大:有的人食指自然伸直时只有 158 度(关节比较松),有的人能到 178 度;用小指短粗的手抓握时角度可能只掉到 120 度。一套写死的阈值在 A 身上完美,在 B 身上就崩,这不是算法问题,是生理差异。
提示:标定过程中一定要保持手掌朝向摄像头、距离稳定。标定得到的基准只在这个姿态下有效,如果实际使用中手掌姿态变化很大,建议针对"正对"和"侧对"各标一次,运行时按手掌朝向自动选一套参数。
5.2 不同条件下的参数参考与调整方向
下面这张表是我在几种典型条件下总结出来的调整方向,你可以当作排查清单用,比盲目调数字高效。
| 现象 | 最可能的原因 | 调整动作 |
|---|---|---|
| 五指张开时总有一根被判弯曲 | 该手指伸展基准偏低 | 把该手指的伸展阈值下调 10 到 15 度 |
| 握拳时总有一两根被判伸出 | 弯曲阈值太高,或该手指弯曲角度不到位 | 弯曲阈值上调 10 度,或改用 DIP 点 |
| 半握状态数字乱跳 | 迟滞带宽太窄 | 伸展与弯曲阈值差拉到 35 度以上 |
| 手离摄像头远时失效 | 关键点精度下降,距离比失真 | 加大窗口帧数,放宽距离判据的权重 |
| 手离得很近时失效 | 手指超出画面,关键点被裁掉 | 把摄像头抬高或拉远,保证手腕也在画面内 |
| 灯光响应慢半拍 | 去抖窗口太长 | 窗口从 7 降到 5,票数从 5 降到 3 |
光照这块也值得单独提一句。我原来以为只要人眼能看清就行,实际测试发现侧逆光是最致命的——手掌一侧被照亮、另一侧全黑,模型给出的关键点会明显偏向亮的一侧,角度整体偏移十几度。解决方式很土但很有效:在摄像头正上方加一个均匀的面光源,或者把装置挪到没有单侧强光的位置。硬件上花五分钟,比算法上调半天管用得多。
6. 常见问题与排查实录
6.1 问题速查表:从现象直达根因
调试这类系统最怕的就是"灯不对",但不知道错在哪一层。我把自己踩过的坑整理成表,按现象查,基本能定位到具体环节。
| 现象 | 排查顺序 | 典型根因 |
|---|---|---|
| 完全没有灯亮 | 先看上位机有没有打印数字 | 串口没连上、波特率不一致、下位机没复位 |
| 灯一直全亮 | 看下发的是不是一直是 5 | 拇指被判为常伸,或关键点索引搞错了 |
| 数字比实际少 1 | 逐根打印每根手指的判定结果 | 小指或无名指的阈值偏严,或三点选取错误 |
| 数字比实际多 1 | 重点看拇指 | 握拳时拇指贴在掌侧,距离判据失效 |
| 数字在两值之间跳 | 看去抖窗口是否生效 | 窗口未填满、或confirmed为 None 被当成 0 |
| 画面翻转后左右手判定反了 | 检查图像翻转位置 | 先翻转再推理,但左右手标签随之反转 |
| 快速比划时识别跟不上 | 测单帧耗时 | 模型复杂度设太高,或画面分辨率过大 |
| 运行几分钟后卡死 | 看是否阻塞在串口读 | parseInt超时阻塞主循环 |
关于"左右手标签反转"这一条,我多说两句。图像做了水平镜像之后,模型输出的左右手标签会跟着反,如果你在代码里用这个标签去区分左右手(比如做双手十位个位),一定要记得反过来判。这个坑我踩过一次,表现是"左手比 1 右手比 2,结果识别成 21 而不是 12",找了很久才发现是镜像的问题。对于单手任务,最简单的做法是完全不理会左右手标签,直接取第一只手,反正数字跟左右手无关。
6.2 几条踩出来的经验,文档里不会写
第一条:永远先打印原始角度值,再相信最终结果。我在调试阶段养成了一个习惯,画面上不但显示识别出的数字,还在旁边用小字列出五根手指的角度和距离比。这个习惯救了我很多次——有一次现象是"无名指永远不会被判伸出",打印出来才看到它的角度一直卡在 149 度,而阈值是 150 度。差一度,就是一个手指的问题。这种边界问题光看最终结果根本发现不了。
第二条:关键点模型给的手腕点,在你手指弯曲时会轻微移动。由于我们用了掌宽做归一化分母,这个移动会污染所有距离比值。缓解办法是不要用单帧的掌宽,而是维护一个掌宽的滑动中位数(比如最近 15 帧的中位数),用它做分母。中位数天然抗单点异常,一改之后距离比值的波动肉眼可见地变小了。
第三条:不要试图用一套阈值覆盖所有姿态。手掌正对镜头时角度读数是准的,手掌侧对时角度会被"投影压缩",本来 170 度可能读成 150 度。我的处理是加一个"手部姿态有效性检查":用腕点、食指根、小指根三个点构成的手掌平面法向估算朝向,朝向过于侧面的帧直接丢弃,不做判决,维持上一帧状态。宁可少识别几帧,也不要在无效帧上瞎判。
第四条:去抖的窗口长度和帧率是绑定的,不是绑定的时长。我在台式机上用窗口 7 感觉很跟手,把代码拷到一台老笔记本上跑,帧率掉到 12 帧,同样的窗口 7 变成了 0.6 秒延迟,用户体验直接崩坏。正确做法是按时间来算,比如"确认窗口 200 毫秒",运行时用实测帧率换算成帧数。这个改动让代码在不同机器上表现一致,属于工程上必须做的事。
最后说一个我在实际装置上做的取舍。这套系统理论上可以做到非常稳——窗口拉长、阈值拉宽、无效帧全丢,稳定得像块石头。但那样响应会很迟钝,用户比完手势要愣一下灯才亮,体验就毁了。我最终的配置是:窗口 7 帧、5 票确认、迟滞带宽 40 度、侧向帧丢弃阈值 55 度。这套参数在三个人、两种光照条件下试过,都能做到"手指一稳住,灯几乎同步亮,中间不乱跳"。如果你追求更极致的手感,可以把窗口压到 5 帧 3 票,代价是偶尔会多闪一下,看你更在意哪个。
后续如果想往下做,我建议的方向不是提高数字识别的准确率,而是加入动作序列判断——比如"连续两次比 3"触发一个特殊效果,"由 0 到 5 依次扫过"做一次流水灯。单纯的一个数字能表达的信息太少了,而序列能表达的东西是指数级增长的,且用的还是同一套底层判据,几乎不需要额外成本。