ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

纯C++ OpenCV手势识别:从肤色分割到凸缺陷手指计数实战

纯C++ OpenCV手势识别:从肤色分割到凸缺陷手指计数实战

简介:这是一份面向计算机视觉初学者与C++开发者的手势识别实战代码资源,聚焦于利用OpenCV实现手掌检测与手指计数。资源包内仅含1个cpp源文件,压缩包体积约2KB,文件精简但覆盖完整流程,便于直接编译运行与二次修改。代码围绕视频流捕获、灰度化与高斯模糊预处理、Canny边缘检测、findContours轮廓提取、最大面积手部区域筛选,以及沿手部边缘投影峰值定位指尖等关键环节展开,最终统计手指数量。已有2256人学习下载,说明该实现具备一定参考价值。读者可从中获得一套可运行的手势识别基础框架,理解从图像预处理到轮廓分析、形状识别的技术链路,并以此为起点扩展至智能家居控制、游戏交互等应用场景,适合作为计算机视觉入门练手与课程设计参考。

1. 从一张手掌照片到五根手指:这套 C++ 手势识别资源到底能跑出什么

很多人第一次接触手势识别,都是从 Python + MediaPipe 那条路进来的,几行代码就能出关键点,爽是爽,但一旦要嵌到工控机、嵌入式板子或者已有的 C++ 桌面程序里,Python 那套运行时和依赖就成了累赘。这份资源走的是另一条路:纯 C++ 搭配 OpenCV,不依赖任何深度学习推理框架,靠肤色分割、形态学处理和轮廓几何分析,把一只手掌从摄像头画面里抠出来,再数出手指根数。它解决的不是“识别 21 个关键点”这种精细活,而是“在一台普通 x86 机器上,用最少的依赖,把 0 到 5 的静态手势稳定读出来”。适合谁?适合手头有 C++ 工程、需要把手势当输入信号、又不想引入庞大推理引擎的从业者;也适合想搞懂传统视觉管线到底怎么串起来的新手。它不承诺复杂背景下的鲁棒性,但在受控光照和干净背景下,这套流程的每一行你都能看懂、能改、能调。

2. 肤色分割与轮廓提取:手掌检测的管线怎么搭

2.1 为什么先做 YCrCb 而不是直接上 HSV

OpenCV 里做肤色检测,新手最容易一头扎进 HSV,因为直觉上“肤色是橙黄色的,调 H 通道就行”。但 HSV 的 H 通道对光照强度极其敏感,同一只手在灯下和窗边,H 值能飘出几十度,阈值根本没法固定。YCrCb 把亮度 Y 和色度 Cr、Cb 拆开,肤色在 Cr-Cb 平面上聚成一个相对紧凑的椭圆区域,光照变化主要影响 Y,对 Cr、Cb 的扰动小得多。常见做法是先把 BGR 转成 YCrCb,再用 Cr 在 133 到 173、Cb 在 77 到 127 这个经典区间做掩膜。这个区间不是万能钥匙,不同摄像头白平衡不一样,但它是绝大多数场景的合理起点。

#include <opencv2/opencv.hpp> #include <vector> // 输入 BGR 帧,输出二值肤色掩膜 cv::Mat skinMask(const cv::Mat& bgr) { cv::Mat ycrcb; cv::cvtColor(bgr, ycrcb, cv::COLOR_BGR2YCrCb); // 转到 YCrCb 空间 cv::Mat mask; // 经典肤色区间:Cr 133-173,Cb 77-127 cv::inRange(ycrcb, cv::Scalar(0, 133, 77), // Y 不限制,Cr 下限,Cb 下限 cv::Scalar(255, 173, 127), // Y 不限制,Cr 上限,Cb 上限 mask); // 中值滤波去掉椒盐噪点,核大小必须是奇数 cv::medianBlur(mask, mask, 5); // 开运算:先腐蚀后膨胀,断开手指间细连接、去掉小噪块 cv::Mat kernel = cv::getStructuringElement(cv::MORPH_ELLIPSE, cv::Size(5, 5)); cv::morphologyEx(mask, mask, cv::MORPH_OPEN, kernel); // 闭运算:填补手掌内部因反光造成的小孔洞 cv::morphologyEx(mask, mask, cv::MORPH_CLOSE, kernel); return mask; }

这段代码的逻辑顺序不能乱。先inRange得到粗掩膜,此时画面里除了手,往往还有脸、胳膊、木质桌面这些同样落在肤色区间的干扰。medianBlur的核大小直接决定去噪力度,5 是常用值,调到 7 以上会把细手指也糊掉。开运算负责“断开”,因为手指之间经常因为阴影连成一片,开运算能把那层薄连接腐蚀断;闭运算负责“补洞”,手掌心反光会在掩膜里留黑洞,闭运算填上。两个运算的核都用椭圆而不是矩形,是因为椭圆核更贴合手部圆润边缘,矩形核会在轮廓上留下方块状毛刺,后面找凸包时容易多出假顶点。

2.2 轮廓筛选:怎么从一堆色块里认出“这是手”

拿到掩膜后,findContours会返回一堆轮廓,脸、胳膊、桌面反光各算一个。这时候不能直接拿面积最大的那个当手,因为胳膊往往比手掌面积还大。筛选逻辑要组合几个几何特征:面积、凸性、以及轮廓面积与外接矩形面积的比例。手掌的轮廓面积通常占外接矩形的 50% 到 75%,太瘦长的(比如胳膊)比例偏低,太方正的(比如显示器边框反光)比例偏高。

// 从掩膜中筛选出最可能是手掌的轮廓 std::vector<std::vector<cv::Point>> contours; cv::findContours(mask, contours, cv::RETR_EXTERNAL, cv::CHAIN_APPROX_SIMPLE); int bestIdx = -1; double bestScore = 0; for (size_t i = 0; i < contours.size(); ++i) { double area = cv::contourArea(contours[i]); if (area < 3000) continue; // 太小的直接丢,噪点或远处干扰 cv::Rect br = cv::boundingRect(contours[i]); double rectArea = static_cast<double>(br.width) * br.height; double fillRatio = area / rectArea; // 填充率 // 手掌填充率经验区间 if (fillRatio < 0.4 || fillRatio > 0.85) continue; // 用面积和填充率的组合打分,选最优 double score = area * fillRatio; if (score > bestScore) { bestScore = score; bestIdx = static_cast<int>(i); } } if (bestIdx < 0) return; // 本帧没找到手,直接跳过

area < 3000这个阈值和分辨率强相关。640×480 下 3000 大致对应一只距离摄像头半米的手;如果你用 1280×720,这个值要翻倍到 12000 左右,否则远处的小手会被误滤。fillRatio的上下限也不是死的,手张开时填充率会降,握拳时会升,0.4 到 0.85 是覆盖大多数静态手势的折中。打分函数用面积乘填充率,是为了让“大且饱满”的轮廓胜出,避免选到面积大但形状松散的胳膊。

2.3 凸包与凸缺陷:手指计数的几何依据

手掌轮廓拿到后,数手指靠的是凸缺陷(convexity defects)。原理很直白:把轮廓的凸包画出来,手指之间的凹槽就是凸包和实际轮廓之间的“缺口”,每个缺口对应两根手指之间的缝隙。凸缺陷由四个值描述——起点、终点、最远点、最远距离。最远距离太小的缺口是噪声,要滤掉;剩下的缺口数量加一,大致就是手指根数(因为 N 个缺口分隔出 N+1 个凸起,其中一个是手腕方向,需要排除)。

// 基于凸缺陷统计手指数量 std::vector<int> hullIndices; cv::convexHull(contours[bestIdx], hullIndices, false, false); // 把凸包索引转成点,并做闭合处理 std::vector<cv::Point> hullPoints; for (int idx : hullIndices) hullPoints.push_back(contours[bestIdx][idx]); hullPoints.push_back(hullPoints[0]); // 首尾相连 std::vector<cv::Vec4i> defects; cv::convexityDefects(contours[bestIdx], hullIndices, defects); int fingerCount = 0; for (const auto& d : defects) { // d[3] 是最远点到凸包边的距离,单位是像素的 1/256 double depth = d[3] / 256.0; if (depth < 20.0) continue; // 太浅的缺口是噪声 cv::Point start = contours[bestIdx][d[0]]; cv::Point end = contours[bestIdx][d[1]]; cv::Point far = contours[bestIdx][d[2]]; // 用夹角进一步过滤:手指间夹角通常小于 90 度 double ax = start.x - far.x, ay = start.y - far.y; double bx = end.x - far.x, by = end.y - far.y; double dot = ax * bx + ay * by; double magA = std::sqrt(ax * ax + ay * ay); double magB = std::sqrt(bx * bx + by * by); if (magA < 1e-6 || magB < 1e-6) continue; double cosAngle = dot / (magA * magB); double angle = std::acos(std::max(-1.0, std::min(1.0, cosAngle))) * 180.0 / CV_PI; if (angle < 90.0) fingerCount++; } fingerCount += 1; // 缺口数 + 1 才是手指数

depth < 20.0这个阈值是血泪经验堆出来的。设太小,手掌边缘的微小起伏会被当成手指缝,数出七八根手指;设太大,手指并拢时的浅缺口会被漏掉,数出三根。20 像素在 640×480 下是个平衡点,分辨率变了要按比例调。夹角过滤是第二道保险,手腕和手掌之间的那个大缺口角度往往接近 180 度,用 90 度阈值能把它排除掉,否则每帧都会多算一根。d[3]除以 256 是因为 OpenCV 的convexityDefects返回的深度是定点数,这个细节不查文档很容易翻车,直接拿原始值比较会得到完全错误的结果。

3. 从静态帧到实时视频流:工程化落地的几个关键改动

3.1 摄像头采集与帧率控制

把上面这些函数串成一个while循环,接上VideoCapture,就能跑实时。但直接while(true)读帧会有一个隐蔽问题:处理一帧的时间如果超过采集间隔,VideoCapture的内部缓冲会堆积旧帧,你看到的画面延迟越来越大。常见做法是每轮循环多读一帧丢弃,或者把CAP_PROP_BUFFERSIZE设成 1。

cv::VideoCapture cap(0); if (!cap.isOpened()) return -1; cap.set(cv::CAP_PROP_FRAME_WIDTH, 640); cap.set(cv::CAP_PROP_FRAME_HEIGHT, 480); cap.set(cv::CAP_PROP_BUFFERSIZE, 1); // 减少缓冲堆积 cv::Mat frame; while (true) { cap >> frame; if (frame.empty()) break; cv::Mat mask = skinMask(frame); // ... 轮廓筛选、凸缺陷计数 ... cv::imshow("Gesture", frame); if (cv::waitKey(1) == 27) break; // ESC 退出 } cap.release(); cv::destroyAllWindows();

CAP_PROP_BUFFERSIZE不是所有后端都支持,V4L2 下有效,某些 Windows 后端会忽略。如果不生效,就在cap >> frame之后再调一次cap.grab()把旧帧吃掉。分辨率设 640×480 是权衡:再低手指细节不够,再高处理耗时线性上升,普通 CPU 上 30fps 就保不住。waitKey(1)里的 1 毫秒不是随便写的,它同时驱动 GUI 事件循环,设 0 会阻塞到按键,设太大则画面卡顿。

3.2 把检测结果画出来:调试可视化的正确姿势

调参阶段最忌讳只看最终数字,一定要把中间结果画出来。掩膜、轮廓、凸包、凸缺陷分四个窗口显示,哪一步出问题一眼就能定位。很多人只画最终手指数,结果数错了完全不知道是分割烂了还是缺陷过滤太狠。

// 在原始帧上叠加调试信息 cv::drawContours(frame, std::vector<std::vector<cv::Point>>{contours[bestIdx]}, -1, cv::Scalar(0, 255, 0), 2); // 绿色:手掌轮廓 // 画凸包 for (size_t i = 0; i < hullPoints.size() - 1; ++i) { cv::line(frame, hullPoints[i], hullPoints[i + 1], cv::Scalar(255, 0, 0), 2); // 蓝色:凸包 } // 画凸缺陷的最远点 for (const auto& d : defects) { double depth = d[3] / 256.0; if (depth < 20.0) continue; cv::circle(frame, contours[bestIdx][d[2]], 5, cv::Scalar(0, 0, 255), -1); // 红色:缺陷点 } // 左上角显示手指数量 cv::putText(frame, "Fingers: " + std::to_string(fingerCount), cv::Point(20, 40), cv::FONT_HERSHEY_SIMPLEX, 1.0, cv::Scalar(0, 255, 255), 2);

颜色约定建议固定下来:绿轮廓、蓝凸包、红缺陷点。这样截图发出去别人也能看懂。putText的字体大小 1.0 在 640×480 下刚好,再大就遮画面。调试窗口和主画面分开,imshow多个窗口时注意窗口名不要重复,否则会互相覆盖。

3.3 参数怎么调:一张对照表

这套管线的参数不多,但每个都直接影响结果。下面这张表是我在实际调试中反复验证过的起点值,你可以在此基础上微调。

参数作用常用值调大后果调小后果
Cr 下限肤色掩膜下界133漏掉偏白肤色引入更多背景
Cr 上限肤色掩膜上界173引入偏红背景漏掉偏深肤色
Cb 下限肤色掩膜下界77漏掉偏黄肤色引入偏蓝背景
Cb 上限肤色掩膜上界127引入偏蓝背景漏掉偏黄肤色
中值滤波核去噪力度5手指边缘糊掉噪点残留
最小面积轮廓筛选3000漏掉远处的手误选噪点
填充率下限轮廓筛选0.4漏掉张开的手误选胳膊
填充率上限轮廓筛选0.85误选方形反光漏掉握拳
缺陷深度阈值手指计数20漏数并拢手指多数手指缝
夹角阈值手指计数90多数手腕缺口漏数手指

这张表不是让你一次全调,而是出问题时知道该动哪个。比如发现手指数总是偏多,先看缺陷深度阈值是不是太小,再看夹角阈值是不是太大。发现手都检测不到,先看最小面积和填充率区间。每次只改一个参数,改完立刻看效果,这是调参的基本纪律。

4. 避坑与排查:这套管线最容易翻车的五个地方

4.1 现象:手指数在 4 和 5 之间反复跳

原因:手指并拢时,指缝的凸缺陷深度刚好在阈值附近,光照微小变化就会让它跨过 20 像素这条线。这是阈值型方法的固有抖动,不是代码 bug。

解决:加一个时间维度的平滑。维护一个长度为 5 的队列,每帧的计数入队,输出取众数。这样单帧的抖动被历史帧压住,代价是响应延迟约 5 帧,在 30fps 下约 160 毫秒,对静态手势完全可接受。

#include <deque> #include <map> std::deque<int> countHistory; const size_t kHistoryLen = 5; int smoothCount(int raw) { countHistory.push_back(raw); if (countHistory.size() > kHistoryLen) countHistory.pop_front(); std::map<int, int> freq; for (int c : countHistory) freq[c]++; int best = raw, bestFreq = 0; for (const auto& kv : freq) { if (kv.second > bestFreq) { bestFreq = kv.second; best = kv.first; } } return best; }

4.2 现象:背景里有一块木色桌面,掩膜里多出一大块

原因:木色在 YCrCb 空间里和肤色高度重叠,inRange无法区分。这是肤色分割的先天局限,不是参数没调好。

解决:两条路。一是加背景减除,用createBackgroundSubtractorMOG2先提取运动区域,再和肤色掩膜做与运算,静止的桌面就被滤掉了。二是限制检测区域,只在画面中央的 ROI 里找轮廓,前提是你的手总在画面中间。前者通用但引入运动依赖,手不动时会丢;后者简单但要求使用姿势固定。我一般先用 ROI 快速验证,需要更鲁棒再上背景减除。

4.3 现象:编译时报convexityDefects相关错误或运行崩溃

原因:convexityDefects要求传入的轮廓必须是CV_CHAIN_APPROX_SIMPLE或类似简化后的轮廓,且凸包索引必须来自同一个轮廓。如果轮廓是CV_CHAIN_APPROX_NONE且点数极多,或者凸包索引和轮廓不匹配,就会出问题。

解决:确认findContours用的是CHAIN_APPROX_SIMPLE,确认convexHull的输入就是选中的那个轮廓,不要拿别的轮廓的索引来用。另外convexityDefects的defects输出在轮廓凸性极强时可能为空,遍历前要判空,否则迭代器越界。

4.4 现象:画面延迟越来越大,像慢动作

原因:处理速度跟不上采集速度,VideoCapture内部缓冲堆积。前面提过CAP_PROP_BUFFERSIZE不一定生效。

解决:在循环里主动丢弃旧帧。读一帧处理,再连续grab两帧丢掉,保证处理的永远是最新画面。代价是有效帧率降低,但延迟稳定。

cap >> frame; cap.grab(); // 丢掉缓冲里的旧帧 cap.grab(); // 再处理 frame

4.5 现象:换一台摄像头,所有参数都失效

原因:不同摄像头的自动白平衡、曝光策略不同,同一肤色在不同设备上映射到 YCrCb 的值能差出十几个单位。参数不是跨设备通用的。

解决:把肤色区间做成可配置项,首次使用新摄像头时跑一个标定流程——把手放在画面中央,采集 30 帧,统计 Cr、Cb 的均值和标准差,用均值加减两倍标准差作为区间。这个标定过程不到十秒,比手动试参数快得多。

5. 进阶技巧:用轮廓曲率替代凸缺陷,把并拢手指也数准

凸缺陷方法有个绕不开的短板:手指并拢时,指缝消失,缺陷深度趋近于零,无论阈值怎么调都数不准。要突破这个限制,得换一个不依赖“缝隙”的特征——轮廓曲率。手指的指尖在轮廓上表现为局部曲率极大值点,即使手指并拢,指尖的圆弧依然存在,只是相邻指尖的曲率峰靠得比较近。用曲率找指尖,再配合指尖到手掌中心的距离做筛选,能在并拢手势下也给出正确计数。

具体做法是:对轮廓做approxPolyDP多边形逼近,逼近精度取轮廓周长的 1% 到 2%,得到一组顶点。然后对每个顶点计算它和前后各 k 个点形成的夹角,夹角小于某个阈值(比如 60 度)的顶点就是候选指尖。k 的取值决定灵敏度,k 太小会把噪声当指尖,k 太大则并拢手指的指尖会被合并。我一般取 k 等于轮廓点数的 2%,在 640×480 下大约是 5 到 8 个点。

// 基于多边形逼近和夹角找指尖 std::vector<cv::Point> approx; double peri = cv::arcLength(contours[bestIdx], true); cv::approxPolyDP(contours[bestIdx], approx, 0.015 * peri, true); // 1.5% 周长精度 int k = std::max(3, static_cast<int>(approx.size() * 0.05)); int tipCount = 0; for (size_t i = 0; i < approx.size(); ++i) { // 取前后各 k 个点(环形索引) cv::Point prev = approx[(i + approx.size() - k) % approx.size()]; cv::Point next = approx[(i + k) % approx.size()]; cv::Point cur = approx[i]; double ax = prev.x - cur.x, ay = prev.y - cur.y; double bx = next.x - cur.x, by = next.y - cur.y; double dot = ax * bx + ay * by; double magA = std::sqrt(ax * ax + ay * ay); double magB = std::sqrt(bx * bx + by * by); if (magA < 1e-6 || magB < 1e-6) continue; double cosAngle = dot / (magA * magB); double angle = std::acos(std::max(-1.0, std::min(1.0, cosAngle))) * 180.0 / CV_PI; if (angle < 60.0) tipCount++; // 尖角判定为指尖 }

approxPolyDP的精度参数 0.015 是周长比例,不是绝对像素。这样换分辨率不用改。k取approx.size() * 0.05是自适应点数,比固定值稳。夹角阈值 60 度比凸缺陷那套的 90 度更严,因为多边形逼近后顶点更稀疏,夹角普遍偏大,不收紧会多算。这套方法的代价是对轮廓质量更敏感,掩膜边缘毛刺会让approxPolyDP产生假顶点,所以前面的形态学处理不能省。

两种方法可以并行跑,凸缺陷结果和曲率结果一致时直接输出,不一致时取较小值——因为凸缺陷倾向于多算(噪声缺口),曲率倾向于少算(并拢合并),取小是个保守但稳的策略。这个融合逻辑我用了很久,在受控环境下基本能做到 0 到 5 的静态手势不出错。从那以后我每次接新的视觉管线,都强制先把中间结果可视化跑一遍,再谈调参和优化。希望帮到你。

本文还有配套的精品资源,点击获取

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进