手势识别:四道工序,把一只手窄化成一个标签
课程最后一个实验。手在画面里往往只占一小块,姿势又有十几种,一步到位认不出来。工程上的做法是接力:先找到手、再钉 21 个点、把点压成一个 128 维向量、最后在 8 个手势里选一个。四道工序各管一段,坏哪一段都能单独看见。
01目标:比个耶就报 Victory,张掌就报 Open_Palm
- 说清四道工序各吃什么、吐什么,以及为什么不能用一个模型直接从图里认出 8 种手势
- 跑通单图与实时两条命令,看到 21 点手部骨架、状态栏
Hands: 1 Fingers: 2 [color] Victory - 解释那个看起来很低的分数
Victory 0.20—— 为什么 0.2 也敢报,报的依据到底是什么
argmax 与「分数门限」的区别。难点:直觉是「0.2 分说明没认出来」,实际是8 个类做 softmax,概率加起来必须是 1;只要它是最大的那一个,就照报。想要「不确定就不报」,得自己加一道分数门限 —— 代码里没有。
一只手(比耶)的照片。也可以换 --ros 的相机画面。
右栏 21 点骨架 + 标签与分数;状态栏 Hands: 1 Fingers: 2 [color] Victory。
02问题:手这么小、姿势这么多,怎么认
想用「比耶」开灯、「握拳」关灯。听上去就是把图和标签对上,可把手举远一点,整只手在画面里可能只有 40×40 像素 —— 比一个图标还小。
如果拿整张图直接做 8 分类,模型要在几十万个像素里找到那只小手,还要分辨手指怎么摆。一步到位太难,所以分成四步,每一步只解决一个窄问题:手在哪 → 手指在哪 → 这只手长什么样 → 它属于哪一类。
| 对比 | 你看到的(黑盒) | 机器必须做的(白盒) |
|---|---|---|
| 比耶被认出 | 手一伸,屏幕就显示 Victory | ? —— 得先把手指的关节位置找到,才谈得上「几根手指伸着」 |
| 为什么分四步 | 以为是一个模型干的 | ? —— 手太小、姿势太多,一步到位认不准 |
| 0.20 是什么意思 | 以为「20% 准」就是没认出来 | ? —— 8 类概率加起来是 1,0.2 可能是里面最高的 |
三个问号拆成三个具体问题:
① 为什么不直接整图分类,非要串四个模型?
② 21 个点在手势判定里到底怎么用?
③ 分数只有 0.20,为什么还敢输出 Victory?
03现象:左框右骨架,状态栏 Victory 0.20
你会看到:双栏图。左栏手 + 绿框 + FPS: 0;右栏黑底,21 个红点用绿线连成手部骨架,旁边蓝字标 Victory 0.20;底部状态栏 FPS: 0 Hands: 1 Fingers: 2 [color] Victory。
Fingers: 2
怎么跑出来:进项目根,加 PYTHONPATH,用模块方式跑单图。
cd ~/ros2_ws/src/xflz_eai_car1/vision/12_hand_control export PYTHONPATH=$PWD/car1_hand_control python -m hand_control_core.demo_image --config config/hand_control_config.yaml --save out.jpg
🔍 看什么:三处,都是后面要用到的证据。
- 21 个点不是均匀铺开 —— 5 根手指各 4 个点(根/中/末)+ 1 个手腕。点密的地方就是要算角度的地方。
- 状态栏有四个字段 ——
Hands(检出几只手)、Fingers(伸了几根手指)、[color](标记来源)、最后才是手势名。 - 分数 0.20 也在报 —— 这就是第 02 节第三问。它不表示「只有 20% 把握所以没认出来」,而是「8 个类里它最大,占 20%」。
这就是本实验最终要呈现的现象。第 05 节会让你换图、接相机,再亲手验证「分数低也照报」这条规则。
04原理:四道工序:找手、21 点、128 维、8 分类
四个小模型接力:先找到手(192 见方),再钉 21 个点(224 见方),把 21 个点压成 128 个数,最后在 8 个手势里取概率最大的那个。
打个比方:像海关查证件 —— 先确认「这是本护照」(找手),再核对上面的照片与指纹(21 点),把信息录成一段编码(128 维),最后在签证类型里打一个勾(8 选 1)。 揭个盖:打勾用的是「哪一栏概率最高」,不是「够不够格」。所以哪怕最高那栏只有 20%,印章照样盖下去 —— 想要「不确定就不盖章」,得自己加一道门限。
四道工序回答第 02 节的三问:① 为什么要串四个 → 步骤 1、2;② 21 点怎么用 → 步骤 2、3;③ 0.20 为什么照报 → 步骤 4。
1为什么第一步只找手、不认手势?
吃什么:整帧图,缩到 192×192。做什么:手掌检测模型找一个手掌框。吐什么:框的坐标 + 分数。
因为「手在哪」和「手在做什么」是两个难度完全不同的任务。检测手掌只需要认「一团有个大拇指的肉色块」,用一个小模型就能做;分类手势则要看清每根手指的姿势,必须在放大后的手上做。
动它会怎样:检测分数低(手太小、逆光)→ 后面三段全部没有输入。这一段的失败会静默传播成「什么都没认出来」,所以排查永远从这一段的框开始。
221 个点是怎么钉上去的?
和 EXP-08 的人脸关键点是同一套思路:把手掌框裁出来、放大到 224 见方,再让关键点模型在这张裁片里钉 21 个点。
- 点怎么分布:手腕 1 个 + 5 根手指各 4 个(根部、中间、末端、指尖),共 21 个。
- 点为什么够用:手势的差别几乎全在「手指弯没弯」—— 关节角度就够了,不需要手的纹理或颜色。
- 为什么手要放大:同 EXP-08 —— 精度来自「手在输入里占多大」,不是模型多大。
动它会怎样:裁片太松 → 手只占一半,点位抖;太紧 → 指尖被切掉,第 3 段拿到的形状就缺一块。
3128 个数是怎么来的?为什么不直接算角度?
这是第 02 节第二问的答案,而且这里有两条并行的路,理解它们的差别比记住模型名更重要:
| 路子 | 怎么得到结论 | 优点 / 代价 |
|---|---|---|
| 学出来的(embedder + classifier) | 21 点 → 128 维向量 → 8 类 softmax | 能认「比耶 / 竖拇指 / 我爱你」这类语义手势;代价是要模型、要数据,概率还可能很低 |
算出来的(fingers.py) | 三点夹角:指尖-中间关节-根部 > 150° 就算这根手指伸直;拇指还要两段都直 | 透明、可解释、不算概率;代价是只能数「几根手指」,认不出语义手势 |
状态栏里 Fingers: 2 来自第二条路(比耶确实伸了 2 根),Victory 来自第一条路。两条路互为交叉验证,这也是工程上常见的做法。
另外 fingers.py 还算一个「拇指-食指夹角」,把 0~70° 映射成 0~255 的连续值 —— 用来做滑杆式的连续控制(捏合程度)。
动它会怎样:STRAIGHT_ANGLE(150°)调低 → 略微弯曲的手指也算伸直,Fingers 会虚高;调高 → 弯一点点就不算。
4分数只有 0.20,为什么还敢报 Victory?
这是全页最重要的一点,也是第 02 节第三问的答案。
分类器输出 8 个数(logits),过 softmax 变成 8 个概率,它们加起来必须是 1。然后代码只做一件事:取最大的那个(argmax)。
- 所以
0.20的意思是:在 8 个候选里它最大,独占 20%,剩下 80% 被其他 7 类分掉了。 - 代码里没有分数门限 —— 只要有 8 个数进来,就一定有一个标签输出。
- 想要「不确定就别报」,必须在外面自己加一道门限(例如
if score < 0.5: 视为 None)。
为什么分数会这么低?因为 softmax 是被「相对差距」驱动的:当 8 个 logit 都很接近时,概率就会平摊。真实场景里手部姿态介于两类之间(比如比耶但中指半弯),就会出现这种「谁都不像」的分布。
动它会怎样:加门限 → 输出更保守,会出现「认得出来但界面没反应」;不加门限 → 永远有反馈,但可能报错。
Victory 0.20 不是「只有两成把握」,而是「8 个类里它最大、占两成」 —— 报,是因为规则只问排名,不问分数。先认识 5 个词
多个模型串行,前一个的输出是后一个的输入。什么时候失效:第一段框错,后面全错,且不会报错。
手腕 + 5 指 × 4 关节,描述手的姿势。什么时候失效:手指交叠、手套、逆光时点位会乱。
把形状压成一串固定长度的数(这里 128 维)。什么时候失效:手型超出训练分布(如戴手套)时向量会漂到没学过的区域。
softmax 把 8 个分数变成和为 1 的概率;argmax 取最大的下标。什么时候失效:只做 argmax 不设门限,就永远「有答案」,哪怕全是噪声。
用余弦定理算指尖-中间-根部形成的角度,判断手指直不直。什么时候失效:侧对镜头时手指前后重叠,角度会算歪。
什么时候会翻车:五个真实边界选读 1 分钟
- 手太小 / 太远 —— 第一段就检测不到,后面全空。表现是「Hands: 0」。
- 手指交叠 —— 21 点会互相穿模,掰指头的角度全乱,
Fingers数错。 - 戴手套 / 深色手 —— 手掌检测对肤色有一定依赖,深色手套上分数明显偏低。
- 镜头侧对 —— 手指前后重叠,夹角退化成接近 180°,明明弯着也算伸直。
- 连续挥动 —— 本页只做单帧判定;要识别「挥手」这类时序手势,得再加一层时间窗口。
| 步 | 数据变成什么 | 为什么非它不可 / 少了会怎样 | 谁在干 | 动参数会怎样 |
|---|---|---|---|---|
| 1 | 整图 → 手掌框(192 输入) | 没有框,后面三段没有输入 | hand_detector | 检测分数低 → 静默失败 |
| 2 | 手掌框 → 21 点(224 输入) | 不钉点,就只剩「一团肉色」,谈不上姿势 | hand_landmarks | 裁片松紧影响点位稳定 |
| 3 | 21 点 → 128 维 | 把「位置/大小」抹掉,只留姿势 | gesture_embedder | 无参数 |
| 4 | 128 维 → 8 类概率 → 一个标签 | 只要 argmax 不要门限,就永远有答案 | canned_classifier | 加门限则更保守 |
只看第 2、5 两列:第 2 列是数据形态变化,第 5 列是能拧的旋钮。第 4 行那句是本页的核心结论。
05实操:单图 / 换图 / 实时三条命令
命令与原理的对应关系。
| 环节 | 函数(文件) | 吃哪个参数 | 对应原理第几步 |
|---|---|---|---|
| 手掌检测 | engine 第一段(hand_detector.rknn) | min_tracking_confidence | 步骤 1 |
| 21 点 | hand.py / 关键点模型 | (无) | 步骤 2 |
| 嵌入 + 分类 | gesture.prepare_embedder_inputs / decode_gesture(gesture.py:28,45) | (无) | 步骤 3、4 |
| 掰指头 | fingers.fingers_up / count_fingers(fingers.py:34,44) | STRAIGHT_ANGLE | 步骤 3 的另一条路 |
| 状态栏 | view.put_status | (无) | 步骤 4 的可视化 |
| 推流 | MjpegServer | server.port(8084) | 链外 |
-
单图跑通,对照两个手势约 3 分钟
命令见第 03 节。判据:终端打印
gesture: Open_Palm,图上有 21 点骨架与标签。[hand_control] gesture: Open_Palm 状态栏:FPS: 0 Hands: 1 Fingers: 5 [color] Open_Palm换一张图再跑:把配置里的
rgb_path改成images/ye.jpg,终端会变成gesture: Victory,状态栏Fingers: 2。 -
故意造两个「认错」约 6 分钟
第一,把手机拍的比耶照片斜过来 45° 再跑 —— 手指前后重叠,21 点开始穿模,
Fingers会数错,手势标签也可能变。第二,手离得远一点,让手在画面里只占很小一块 —— 会看到
Hands: 0,也就是第一段就失败了。这两次失败定义了这套方案的边界。 -
实时相机:看四段一起跑约 8 分钟
实时走 MJPEG,端口 8084(
hand.mjpg)。先起驱动再跑程序,两个终端ROS_DOMAIN_ID一致(N 取 0~101,各设备不同)。终端一 · 起相机驱动
source /opt/ros/humble/setup.bash && source ~/ros2_ws/install/setup.bash export ROS_DOMAIN_ID=N ros2 launch ascamera hp60c.launch.py
日志持续滚动,无 ERROR终端二 · 跑手势识别
source /opt/ros/humble/setup.bash && source ~/ros2_ws/install/setup.bash export ROS_DOMAIN_ID=N cd ~/ros2_ws/src/xflz_eai_car1/vision/12_hand_control export PYTHONPATH=$PWD/car1_hand_control python -m hand_control_core.camera_demo --config config/hand_control_config.yaml --ros --port 8084
[camera_demo] MJPEG on http://<板IP>:8084/hand.mjpg ← 浏览器打开,依次比:握拳 / 张掌 / 比耶 / 竖拇指四个手势挨个比一遍,记下每个的分数。你会发现分数普遍不高(0.2~0.6) —— 这就是 softmax 摊薄的结果,不是模型坏了。
Victory 0.20 / Open_Palm + 状态栏四个字段。而且你现在能解释那个 0.20。两处对上了,四道工序才算真的走通。你该看到的画面
21 点落在关节上,标签与手势一致,Fingers 数与实际伸出数相同。
握拳 → Closed_Fist;竖拇指 → Thumb_Up;Fingers 同步变。
21 点穿模,Fingers 数错,标签可能跳到不相干的类。
手太远 / 太暗 / 被挡。后面三段完全没有输入。
踩坑速查
| 现象 | 原因 | 怎么办 |
|---|---|---|
报 ModuleNotFoundError: hand_control_core | 没加 PYTHONPATH | export PYTHONPATH=$PWD/car1_hand_control,用 -m 跑 |
Hands: 0,什么都没识别 | 手太小 / 太暗 / 第一段失败 | 靠近镜头、给足光;先确认左栏有绿框 |
| 骨架贴错位置 | 21 点在裁片里算的,裁片没对上 | 检查第一段框是否准确;手别超出画面边缘 |
| 分数总是很低(0.2 左右) | softmax 在 8 类间摊薄,不是故障 | 看 argmax 而不是看绝对值;需要保守就自己加门限 |
Fingers 数错 | 手指前后重叠,夹角算歪 | 掌心朝向镜头,别侧对;必要时调 STRAIGHT_ANGLE |
| 换手势后标签不变 | 画面里仍是上一只手(缓存/残影)或握手势太快 | 把手移出画面再进来;实时模式下慢一点切换 |
--ros 报 cannot open ros topic | 驱动没起 / ROS_DOMAIN_ID 不一致 | 先起 ascamera;两终端 export 同一个 N |
进阶 1 · 自己加一道分数门限约 20 分钟
在 decode_gesture 之后加三行:低于阈值就把标签改成 None。你会立刻看到「想比耶但手指没摆好 → 显示 None」,而不是硬报一个错的。
阈值取多少要看场景:演示用 0.3,做控制用 0.6。没有通用值,只有取舍。
进阶 2 · 用拇指-食指夹角做连续控制约 25 分钟
fingers.thumb_index_value 已经把夹角 0~70° 映射成 0~255。把它接到一条滑杆上(比如车的速度档位),就得到「捏得越紧、开得越慢」的连续输入。
这比 8 个离散手势细腻得多 —— 也是「分类」和「回归」两种思路的分界:要选项就用分类,要程度就用回归。
🧪 Vibe Coding · 自己把这段代码读明白约 20 分钟
你要产出三样:模块职责表 → 控制流流程图 → 时序图。
提示词 1 · 模块职责分析
请阅读这个手势识别项目的模块:hand_control_core/engine.py、hand.py、gesture.py、fingers.py、view.py、demo_image.py。 它们共同完成「找手 → 21 点 → 分类 → 显示」。 请输出一张表,每行一个模块,四列:模块名 / 一句话职责 / 被谁调用 / 从哪个配置项取值。 并把四个 .rknn 模型分别标在对应的模块上。
提示词 2 · 控制流流程图
请为 gesture.decode_gesture 与 fingers.fingers_up 两条支路各画一张流程图,然后说明它们的结果如何汇合到状态栏。 必须包含:8 类 logits 如何过 softmax、argmax 如何取标签、伸指判定里三点夹角如何计算、拇指为什么需要两段都直。
提示词 3 · 时序图
请为实时模式画一张时序图,参与者:ros2_camera 节点、Ros2Camera 回调、手掌检测、关键点模型、embedder、classifier、MjpegServer。 标出四次推理的先后(严格串行)与各自的输入尺寸,并估算哪一段最耗时。
自查三问:四个模型各在哪个文件被调用?softmax 与 argmax 是否分别指出?能说出为什么四次推理必须串行(后一个输入依赖前一个输出)?
三处必然的差异:
| 差异点 | AI 版常见写法 | 工程版(本仓) | 为什么工程这么写 |
|---|---|---|---|
| 分类输出 | 直接 argmax,不解释分数,或把分数当「准确率」 | softmax 后取最大并同时保留 8 类分数(gesture.py:45-52) | 8 类概率和为 1,0.2 也可能是最大;保留全部分数便于后续加门限或调试 |
| 手指计数 | 用关键点做简单的「y 坐标比较」 | 三点夹角 > 150°,拇指还要两段都直(fingers.py:13-44) | 手会旋转,比 y 大小换个角度就失效;夹角与朝向无关 |
| 模型串联 | 一个模型直接输出手势 | 四个模型级联,每段输入尺寸固定(192/224/128/8) | 手太小、姿态太多;分段后每段都能单独验证与替换 |
改一处、先预测再验证:把 STRAIGHT_ANGLE 从 150 改成 120,先写下 Fingers 会变大还是变小,再跑一次对答案。
06互动:拖两个 logit,看 softmax 谁赢
📌 这一节是「掌握检查」:亲手改一个参数,看现象是否朝你预期的方向变,还要能说出为什么 —— 说得出,才算掌握。
Victory 0.20 这个数最容易误读。这里把两个类的分数交给你:看它怎么被摊薄、又怎么永远有一个赢家。
怎么读出规律:先把两条都拉到最小 —— 8 类几乎平分,胜者只有 0.13 左右,但它照样输出;再把 Victory 拉满、另一条压低 —— 概率迅速接近 1。你会看到:分数是相对的,标签是绝对的。
8 类各约 0.125,最高那个照样被输出 —— 这正是「噪声也有标签」。
比其它类高出一截,但绝对值不高。第 03 节的 0.20 就是这种形态。
差距足够大时 softmax 会把概率集中到一类上。
概率相同,argmax 只能选一个(实现上取第一个)—— 这就是「模棱两可仍要下结论」。
07验证:三道小测
回页首对照「实验目标」:① 四道工序能否按顺序说出来 → 试 Q1;② 单图与实时跑通、状态栏四个字段能否解释 → 回第 05 节;③ 0.20 能否解释清楚 → 试 Q3,并回第 06 节拖一遍 softmax。三条全 ✓,这门课的 12 个实验就都走完了。
Hands: 0 就说明第一段挂了)。Fingers: 2 是怎么算出来的?Fingers: 2 与 Victory 应该一致。Victory 0.20,正确理解是?08章节总结:四道工序把「手」一路窄化
这页讲了什么,它们怎么串起来
| 概念 | 它解决了什么 | 和别的关系 |
|---|---|---|
| 手掌检测(192) | 在整帧里找到手,给一个框 | 级联的第一段;它失败,后面全空 |
| 21 点关键点(224) | 把手势变成关节坐标 | 上游吃手掌框,下游喂给嵌入与掰指头两条路 |
| 128 维嵌入 | 抹掉位置与大小,只留姿势 | 让分类不受远近左右影响;是「形状」的数值化 |
| 8 类 softmax + argmax | 在固定词汇表里给出唯一标签 | 只比排名不比分数;0.2 也照报 |
| 三点夹角掰指头 | 不训练模型也能数出伸了几根 | 与分类器互为交叉验证;与朝向无关 |
| 拇指-食指夹角 → 0~255 | 把捏合程度变成连续量 | 分类给选项,回归给程度 |
| 状态栏四字段 | 一眼看到「几只/几指/来源/结论」 | Hands/Fingers 出错能立刻定位是哪一段的问题 |
② 直觉是模型越多越准,实际是级联的目的是可分解、可调试,代价是四次串行推理的延迟;
③ 直觉是数手指就比 y 坐标,实际是用手会旋转,必须用三点夹角 —— 换个角度比 y 就全错。
②
STRAIGHT_ANGLE = 150° 判的是三点夹角,不是「手指的绝对角度」;拇指还要两段都大于 150°,因为手腕处多一个关节;③ 状态栏的
Fingers 与手势标签来自两条不同的路,不一致时先怀疑 21 点(手指交叠最容易出问题);④ 端口是 8084(
hand.mjpg),与前三个实验的 8080/8081/8082/8083 全错开。
前置依赖 · 缺一样就跑不起来
| 类型 | 具体项 | 缺了会怎样 / 怎么补 |
|---|---|---|
| 环境 | 板上 Python 在 ~/ros2_venv;实时模式需 ROS 2 Humble 两级 source 与相机节点 | 未激活 venv → python: command not found;未 source ROS → ImportError: librcl_action.so |
| Python 库 | numpy、opencv-python、pyyaml、rknnlite、mjpeg_streamer | 缺 rknnlite 四个模型都加载不了;缺 mjpeg_streamer 实时启动即退出 |
| 模型 / 资源 | 四个 .rknn:hand_detector(2.9MB)、hand_landmarks_detector(5.9MB)、gesture_embedder(448KB)、canned_gesture_classifier(92KB);示例图 hand_demo.jpg / ye.jpg | 缺任一模型,对应那一段直接失败;换图要保证手够大、掌心朝镜头 |
| 配置 | config/hand_control_config.yaml:min_tracking_confidence: 0.5、source.mode: image、rgb_path、server.port: 8084 | 缺 key 抛 ConfigError;mode: image 没写 rgb_path 直接报错 |
| 运行方式 | 项目根 + PYTHONPATH 含 car1_hand_control,用 -m 运行 | 直接跑 .py 会因相对导入失败 |
这套代码用在哪儿
- 无接触交互:隔空翻页、比手势开关灯 —— 医疗、洁净车间里不能碰屏幕的场景。
- 机器人手势指令:握拳停、张掌走、比耶召回,一套 8 个标签足够覆盖常见指令。
- 连续控制:拇指-食指夹角 → 速度档位,比离散手势细腻,适合调音量、调速这类量。
- 不该用它的场合:需要毫米级精度时别用手势(用 EXP-10 的深度);复杂指令集(几十种手势)时分类器会迅速饱和,该上自定义训练。
十二个实验到此走完。回头看这条主线:像素 → 掩膜 → 轮廓 → 检测 → 分类 → 分割 → 跟踪 → 关键点 → 深度 → 动作。每一格都是「把上一格的输出,变成下一格能吃的形态」。这套思路,比任何单个模型都更值得带走。