拓十年匠心定制 · 商业建站与技术教学双线并行 咨询热线:400-886-1026 service@lmnt.cn
ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

OpenCV+Python指尖检测:背景减除、凸包与键盘模拟

OpenCV+Python指尖检测:背景减除、凸包与键盘模拟

简介:面向Python与OpenCV开发者的手势识别实战资料,基于GitHub开源项目改进后支持手指指尖检测,并能在Windows平台通过手指数目模拟键盘按键,实现简单的免触控交互。整个资源为1个PDF文件,仅233KB,内容以完整源码为主干,配有逐段中文注释,并给出环境说明(Python3.6+OpenCV3.4.0)与核心参数的含义及可调范围;代码覆盖背景减除、高斯模糊、二值化、轮廓提取、凸包绘制、指尖定位与计数、模拟按键等关键环节,从摄像头读取到输出控制一应俱全,很适合初次接触手势识别或希望快速跑通Demo的学习者。已有1992人学习,阅读时既可以按代码顺序跟踪处理流程,也可以把指尖检测部分移植到自己的项目中二次开发,或在此基础上调整阈值、修改按键映射以适配不同场景。

1. 手势识别:这可能是你最快跑通的指尖检测方案

用 Python 做手势识别,很多人第一反应是上 MediaPipe 或深度学习模型,但如果你只需要在 Windows 下通过手指数目触发键盘操作,OpenCV 的经典方案反而更直接:不需要训练、不需要 GPU、单个 Python 文件就能跑。本文要拆的这个项目来自 GitHub 上的 Fingers-Detection-using-OpenCV-and-Python,原作者实现了基于背景减除的指尖检测,我在此基础上补充了指尖坐标过滤逻辑和 win32api 键盘模拟,让整个程序可以在 Windows 下用 2 根手指、3 根手指去触发按键。

这个方案的核心思路不是靠肤色检测,而是靠背景减除:先让摄像头记住一帧没有手的背景,然后把每一帧画面和背景做差分,前景就是你的手。整个过程只依赖 OpenCV 的图像处理函数和 numpy 运算,原理透明、参数可调,非常适合作为手势识别入门项目来复现。适合的人群很明确:想理解 OpenCV 轮廓分析和凸包原理的人、需要在本地用摄像头做交互控制的人、以及不想引入大模型依赖的嵌入式或桌面端开发者。

2. 从背景建模到二值化:先把「手」从画面里抠出来

2.1 为什么选 MOG2 而不是肤色检测

这个项目最关键的一步是cv2.createBackgroundSubtractorMOG2。我去查了原项目的设计意图,作者选它而不是肤色检测,是因为肤色模型对光线和白平衡极其敏感:同一个人的手,在暖光灯和 LED 灯下 YCrCb 范围的分布完全不同,肤色阈值调一次换一个环境就失效。而背景减除不关心你手是什么颜色,只关心画面里什么东西动了、什么东西没动。

MOG2 的全称是 Mixture of Gaussians v2,OpenCV 内置的自适应混合高斯背景建模。它的工作方式是对每个像素建立多个高斯分布,一个像素如果长期保持相近的灰度值,就被归入背景模型;一旦某个像素的灰度值和背景模型差异超过阈值,就被判定为前景。createBackgroundSubtractorMOG2(history, varThreshold, detectShadows)的三个参数中,history 决定用多少帧来训练背景模型,varThreshold 决定判定前景的方差阈值,detectShadows 用于是否检测阴影。

bgModel = cv2.createBackgroundSubtractorMOG2(0, bgSubThreshold)

这段代码里 history 传了 0,表示使用默认的 200 帧;bgSubThreshold的值是 50,表示像素值和背景模型的马氏距离超过 50 就认为是前景。注意 MOG2 的默认参数对阴影检测是开启的,原项目没有单独处理阴影,这在实际运行中会导致手部边缘出现灰黑色的噪点块。我一般会把 detectShadows 显式设为 False,减少后续轮廓提取的干扰:

bgModel = cv2.createBackgroundSubtractorMOG2(history=500, varThreshold=bgSubThreshold, detectShadows=False)

history 从 0 改成 500,目的是让背景模型在窗口移动、窗帘飘动时更稳定。如果你的摄像头固定不动,这个值可以更大;如果摄像头是手持的,建议降到 100 以下,否则背景模型更新太慢,手的残影会残留在前景里。

2.2 removeBG 里的腐蚀操作到底在干什么

removeBG函数是整条处理链的入口,它做的事情不止是前景提取,还加了一次腐蚀:

def removeBG(frame): fgmask = bgModel.apply(frame, learningRate=learningRate) kernel = np.ones((3, 3), np.uint8) fgmask = cv2.erode(fgmask, kernel, iterations=1) res = cv2.bitwise_and(frame, frame, mask=fgmask) return res

bgModel.apply返回的是一张单通道掩膜,白色区域代表前景,黑色代表背景。直接用这张掩膜去截原图,你会看到手的边缘有一圈毛刺——这是摄像头噪声和背景模型边缘像素误判造成的。腐蚀的作用就是把这些毛刺削掉:kernel 是 3×3 的全 1 矩阵,iterations=1 表示执行一次腐蚀,效果是让白色区域的边界向内收缩 1 个像素。代价是手指尖也会变细一圈,对于指尖检测来说,这反而有利于减少后续轮廓的锯齿。

learningRate传的是 0,这个参数另有含义。MOG2 的 apply 方法里,learningRate 为负数(默认 -1)表示自动更新背景模型,传 0 表示这一帧完全不更新背景。原项目里学习率恒为 0,意味着背景模型只在按 b 键那一刻被初始化,之后永远不会学习新的背景变化。这在固定场景下是优点——手不会慢慢被融入背景,但你如果中途移开摄像头或者背景里多了个物体,前景就会一直存在直到按 r 重置。

2.3 双边滤波和高斯模糊的分工:一个保边,一个降噪

预处理阶段有两个容易搞混的滤波:开头对原始帧做的是cv2.bilateralFilter,对前景掩膜做的是cv2.GaussianBlur。前者是双边滤波,它的特点是同时考虑像素的空间距离和灰度差异,所以在平滑噪声的同时能保留边缘。用在原始帧上,是为了让背景建模时像素的灰度分布更稳定,避免传感器噪点导致背景模型误判。

frame = cv2.bilateralFilter(frame, 5, 50, 100)

参数依次是 d=5、sigmaColor=50、sigmaSpace=100。d 是滤波窗口直径,5 意味着考虑每个像素周围 5×5 的邻域;sigmaColor 越大,灰度差异大的像素越容易被一起平滑,边缘保留能力越弱;sigmaSpace 越大,远处像素对当前像素的影响越大。我实测下来,这三个参数在 640×480 的摄像头画面上表现不错,但如果你用的是 1080p 画面,d 要适当加大到 7,否则平滑效果不足。

高斯模糊则用在灰度图上,目的是消除二值化时产生的椒盐噪声:

blur = cv2.GaussianBlur(gray, (blurValue, blurValue), 0)

blurValue设为 41,这是一个偏大的核。41×41 的高斯核意味着每个输出像素是周围 41×41 范围内像素的加权平均,对轮廓的平滑作用很明显,代价是丢失细小的凸起。如果手指很细或者摄像头距离手较远,可以考虑把 blurValue 降到 21,否则指尖可能被高斯模糊抹平,导致后面距离法找不准最远点。

2.4 阈值滑块的正确姿势:先调亮度,再调阈值

程序启动时会创建一个名为 trackbar 的窗口,里面放了一个 threshold 滑条,范围 0 到 100。这里的阈值是二值化的分界线:灰度图上像素值大于 threshold 的变成 255(白),小于的变成 0(黑)。

cv2.createTrackbar('threshold', 'trackbar', threshold, 100, printThreshold)中,初始值 threshold 是全局变量 60,回调函数 printThreshold 只是把当前阈值打印出来。实际操作时我建议按这个顺序校准:先按 b 捕获背景,然后对手放在摄像头前,观察 binary 窗口,如果手是黑色、背景是白色,说明阈值反了,需要把阈值调低;如果手上有大片空洞,说明阈值太高,背景的灰度值也被算进了前景。

有个细节值得注意:cap_region_x_begin = 0.5和cap_region_y_end = 0.8限定了感兴趣区域。程序只在画面右半部分、从上往下 80% 高度的区域内做手势检测,画面上用红色矩形标出了这个区域。这个设计是有意的——大多数人的操作习惯是右手放在摄像头右侧,左手操作键盘,所以只检测右侧区域能减少误判。如果你习惯左手手势,把cap_region_x_begin改成 0.0 并调整矩形框绘制逻辑就行。

3. 指尖检测:轮廓、凸包和距离法的三角关系

3.1 从二值图到轮廓:findContours 的坑从版本开始

二值化之后,thresh是一张黑白图,白色区域就是手。接下来要找到手的轮廓,OpenCV 提供了findContours。这里有一个版本差异的坑在文末的补充里明确写了:OpenCV 3.x 时代返回三个值,OpenCV 4.0 之后返回两个值。

thresh1 = copy.deepcopy(thresh) _, contours, hierarchy = cv2.findContours(thresh1, cv2.RETR_TREE, cv2.CHAIN_APPROX_SIMPLE)

第一版代码针对的是 OpenCV 3.4.0,所以用了三个返回值。copy.deepcopy(thresh)很关键——findContours会直接修改输入图像,如果不拷贝,后续想再显示二值化结果就会拿到一张被破坏的图。cv2.RETR_TREE表示建立轮廓的层级树,cv2.CHAIN_APPROX_SIMPLE则用最少的点来编码轮廓,只保留端点。

如果你装的是 OpenCV 4.x,直接跑这段会报错说解包的值太多,解决方式很简单:

contours, hierarchy = cv2.findContours(thresh1, cv2.RETR_TREE, cv2.CHAIN_APPROX_SIMPLE)

我自己的经验是,与其改代码,不如在同一条处理链里做版本兼容:

cnt_info = cv2.findContours(thresh1, cv2.RETR_TREE, cv2.CHAIN_APPROX_SIMPLE) if len(cnt_info) == 3: _, contours, hierarchy = cnt_info else: contours, hierarchy = cnt_info

这样无论队友用的是 3.4 还是 4.5,代码都不会炸。

3.2 为什么只取最大轮廓:凸包检测的数学基础

轮廓可能有多个,比如手旁边放了杯水、或者背景减除没做干净留下了一块噪点。项目里用面积法筛掉小轮廓:

length = len(contours) maxArea = -1 if length > 0: for i in range(length): temp = contours[i] area = cv2.contourArea(temp) if area > maxArea: maxArea = area ci = i res = contours[ci]

cv2.contourArea计算的是轮廓包围的面积,不是像素点数,因为轮廓是折线,直接数点数会低估面积。取最大轮廓后,下一步是计算凸包:

hull = cv2.convexHull(res)

凸包是包含轮廓所有点的最小凸多边形。指尖检测依赖凸包的原因是:手指伸出来时,指尖是轮廓上离手心最远的点,同时也是凸包的顶点。非凸的凹点(比如指缝)不可能是凸包顶点,所以凸包可以直接过滤掉指缝形成的干扰点。cv2.drawContours(drawing, [hull], 0, (0, 0, 255), 3)用红色画凸包,绿色画原始轮廓,调试时一眼就能看出凸包是否贴合手指外形。

3.3 重心距离法找指尖:为什么不用凸包缺陷

算出凸包之后,主流的指尖检测思路有两种:一种是基于凸包缺陷(convexity defects),通过寻找轮廓上凹陷最深处的点来推断指缝位置,再反推指尖;另一种是本文采用的距离法,直接找轮廓上离重心最远的点。原作者选择了后者,代码里先算轮廓的矩:

moments = cv2.moments(res) center = (int(moments['m10'] / moments['m00']), int(moments['m01'] / moments['m00']))

这里用零阶矩 m00 归一化一阶矩 m10 和 m01,得到的是轮廓质心。在手势识别中,质心和重心的差异可以忽略,用矩计算是最稳的,因为它对轮廓形状不敏感。

指尖检测的核心逻辑是遍历轮廓上的每个点,计算它到重心的距离平方:

fingerRes = [] max = 0; count = 0; notice = 0; cnt = 0 for i in range(len(res)): temp = res[i] dist = (temp[0][0] - center[0])**2 + (temp[0][1] - center[1])**2 if dist > max: max = dist notice = i if dist != max: count = count + 1 if count > 40: count = 0 max = 0

这段代码初看有点绕。它不是在找全局最远点,而是在找「局部最远点」:每当 dist 超过当前最大值,就更新 notice;当 dist 不再是最大值且持续了 40 个点以上,说明当前这个最远点是一个独立的凸起,把它记下来,然后重置 max,继续找下一个凸起。count > 40 是防止噪声造成的假峰值被当作指尖——如果只是少数几个点突然变远,count 还没到 40 就被重置了。

为什么用距离平方而不是实际距离?因为math.sqrt是浮点运算,在 Python 的循环里对每个轮廓点都开根号,一帧几十毫秒的延迟就是从这里来的。距离平方的单调性和距离一致,不影响最值比较,但省掉了大量开方运算。这是原项目里很实惠的优化。

3.4 指尖过滤的两道闸门:低于手心不算、靠太近不算

找到局部最远点之后,还有两道过滤,这是原项目里容易被人忽略但实战价值最高的部分。第一道是高度过滤:

if center[1] < res[notice][0][1]: continue

center[1]是重心的 y 坐标,res[notice][0][1]是候选点的 y 坐标。图像坐标系里 y 向下增大,所以center[1] < 候选点 y意味着候选点比重心更低。如果它比手心还低,那大概率是小臂或者手腕的边缘凸起,不是手指。这个假设在手掌朝下、手指向前伸的手势下成立,但如果你做的是手掌朝上的手势,指尖会比重心低,这一条会把所有指尖都过滤掉。用这个项目时手势固定成掌心朝下会更稳。

第二道是距离过滤:

for j in range(len(fingerRes)): if abs(res[notice][0][0] - fingerRes[j][0]) < 20: flag = True break

这是检查候选点和已确认的指尖在 x 坐标上是否太近。x 坐标差小于 20 像素,视为同一个手指的重复检测。注意这里只比了 x 坐标,没比 y 坐标——因为手指竖直伸出时,同一个手指的轮廓点分布在同一 x 区间内的不同高度,x 是最有区分度的维度。如果摄像头是横着放的,这个判断会失效,你需要改成同时比较 x 和 y,或者干脆算两点欧氏距离。

20 这个阈值对应的是 640×480 画面下正常手指宽度的像素量。摄像头分辨率更高时,这个值要等比放大,比如 1280×720 画面下建议调到 40。

4. 从指尖到手指数:键盘模拟与控制逻辑

4.1 win32api 模拟按键:空格键的按下与释放

指尖检测完成后,cnt 就是当前帧识别到的手指数。原项目把它用来控制空格键:

win32api.keybd_event(32, 0, 0, 0) win32api.keybd_event(32, 0, win32con.KEYEVENTF_KEYUP, 0)

keybd_event的第一个参数是虚拟键码,32 对应空格;第二个参数是扫描码,0 表示不指定;第三个参数是标志位,0 表示按下,win32con.KEYEVENTF_KEYUP表示释放。这里有个容易踩的坑:按下和释放之间没有延时,很多程序检测不到这么快的击键。我一般会在两次调用之间加一个time.sleep(0.05),模拟真实按键的持续时间:

import time win32api.keybd_event(32, 0, 0, 0) time.sleep(0.05) win32api.keybd_event(32, 0, win32con.KEYEVENTF_KEYUP, 0)

还有一个问题是连续触发。只要手势保持 3 根手指,每帧都会触发一次按键,一秒钟 30 帧就是 30 次空格。原项目没有做防抖。实际使用时,我会引入一个冷却时间:

last_trigger_time = 0 if triggerSwitch is True and cnt >= 3: current_time = time.time() if current_time - last_trigger_time > 0.5: win32api.keybd_event(32, 0, 0, 0) time.sleep(0.05) win32api.keybd_event(32, 0, win32con.KEYEVENTF_KEYUP, 0) last_trigger_time = current_time

这样做的好处是:想触发翻页,就快速比个 3,然后放下;不用一直举着手。0.5 秒的冷却时间不会让操作变得迟钝,但能挡住 90% 的误触。

4.2 三种模式的状态机:b 捕获、r 重置、n 开启

程序的交互逻辑通过键盘事件切换状态,核心是一个布尔变量isBgCaptured和triggerSwitch。初始状态下,isBgCaptured为 0,程序只做画面显示,不做任何手势检测;按下 b 键,背景模型建立,isBgCaptured变为 1,开始检测指尖;按下 r 键,背景模型置空,回到初始状态;按下 n 键,triggerSwitch变为 True,此时检测到的手指数目才会触发键盘事件。

k = cv2.waitKey(10) if k == 27: break elif k == ord('b'): bgModel = cv2.createBackgroundSubtractorMOG2(0, bgSubThreshold) isBgCaptured = 1 elif k == ord('r'): bgModel = None triggerSwitch = False isBgCaptured = 0 elif k == ord('n'): triggerSwitch = True

注意cv2.waitKey(10)的返回值。它每 10 毫秒读取一次键盘输入,返回的是按键的 ASCII 码。ord('b')取 b 的 ASCII 码,这样比较的是数字而非字符,避免编码问题。还有一个隐藏细节:cv2.waitKey只有在当前窗口获得焦点时才能收到按键,所以你必须先点击 OpenCV 弹出的任一窗口,再按这些快捷键。

这套状态机的设计思路值得借鉴:把「建模」和「触发」分成两个开关,避免摄像头刚启动背景还没建模就误触发键盘。如果你想加一个暂停功能,可以在 n 与 r 之间加一个 p 键,把triggerSwitch设回 False,这样就不用重置背景了。

4.3 打印的真实作用:它是指尖检测的调试工具

代码里在每帧检测完指尖后有一行print(cnt),很多人以为只是单纯的手指数输出。在实际调试中,这行打印的意义远不止于此:当你把程序连接到其他软件(比如游戏、PPT)之前,先通过终端观察 cnt 是否稳定,可以快速判断是检测算法的问题还是键盘模拟的问题。

我会把它扩展成更完整的调试输出:

print(f"frame: {frame_id}, fingers: {cnt}, centers: {center}")

帧号和重心坐标一起输出,能帮你判断指尖检测是不是发生了跳变。跳变的特征是相邻两帧手指数从 2 变成 4 又变成 2,而你的手根本没动——这种情况八成是距离法的 count > 40 阈值太小,或者高斯模糊核不够大。不要开着摄像头去猜,终端输出的数字是最诚实的反馈。

5. 避坑指南:OpenCV 版本、光照和误触这三座大山

5.1 坑一:OpenCV 4.0 后 findContours 返回值变化

现象:运行程序直接报错,提示not enough values to unpack (expected 3, got 2)。

原因:OpenCV 4.0 调整了findContours的接口,旧版本返回(image, contours, hierarchy)三个值,新版本只返回(contours, hierarchy)两个值。项目源代码按 OpenCV 3.4.0 写的三个返回值,在 4.x 下必然崩。

解决:两个方案任选。方案一是降级 OpenCV:pip install opencv-python==3.4.0.14,但要注意 3.4.0 与 Python 3.9 以上版本不兼容,如果你是 Python 3.10 以上的环境,这条路走不通。方案二更推荐:把解包改成自动适配。

cnt_result = cv2.findContours(thresh1, cv2.RETR_TREE, cv2.CHAIN_APPROX_SIMPLE) contours = cnt_result[0] if len(cnt_result) == 2 else cnt_result[1] hierarchy = cnt_result[1] if len(cnt_result) == 2 else cnt_result[2]

这段逻辑的含义是:返回值长度是 2,直接取第 0 个作为轮廓;长度是 3,说明是老版本,轮廓在第 1 个位置。这样写的好处是你的代码在 3.4 到 4.8 的所有版本都能跑。

5.2 坑二:光照一换,手直接融进背景

现象:白天在窗边跑得好好的,晚上把灯一关,手在 binary 窗口里大面积变黑,或者背景里出现大片白色噪点,指尖数量乱跳。

原因:这个项目的背景模型是在按下 b 键那一刻固定的,后续完全不更新。白天和晚上的光线色温不同,MOG2 建的模型是基于白天的灰度分布,晚上灯光一变,所有像素的灰度值整体偏移,背景就和手一起变成了前景。光照突变时,整个画面的灰度分布都变了,背景模型必然失配。

解决:三条路。第一,固定你的环境,拉窗帘、固定灯光,让背景灰度在运行期间尽量不变。第二,把学习率从 0 改成-1,让 MOG2 自动更新背景模型——但这样手静止不动几秒,手也被融入背景,指尖会消失,需要你用动手机制来避免。第三,启动后重新按一下 r 再按 b,重新建模。这个方法最直接,但治标不治本。

我实际项目里是加了一个简单的光照补偿:取画面左上角固定区域的灰度均值,如果与初始背景的灰度均值偏差超过阈值,自动触发一次重新建模。

roi = frame[0:50, 0:50] current_brightness = np.mean(cv2.cvtColor(roi, cv2.COLOR_BGR2GRAY)) if abs(current_brightness - initial_brightness) > 15: bgModel = cv2.createBackgroundSubtractorMOG2(0, bgSubThreshold) isBgCaptured = 1

这里 initial_brightness 是按下 b 键时从同一个位置采的灰度均值,15 的阈值能容忍正常的缓慢光线漂移,又能及时响应灯光切换。

5.3 坑三:静止手势触发键盘连发

现象:只比了 3 根手指没有动,空格键被不停触发,翻页根本停不下来。

原因:代码没有防抖。triggerSwitch为 True 后,只要 cnt >= 3,每帧(约 33 毫秒)都执行一次keybd_event,每秒触发 30 次按键。这在操作系统层面是合法的,但目标程序会认为你按住空格没松手。

解决:加入冷却时间,我自己用的是 0.5 秒阈值,在前文有过代码。这里再补充一点,如果你希望手势保持时只触发一次,还可以加状态锁:

prev_cnt = 0 if cnt != prev_cnt and cnt >= 3: # 触发按键 prev_cnt = cnt

逻辑是只有手指数发生变化时才触发,手势保持不动则不产生新事件。这个方案适合「比 3 翻页、比 2 返回」的场景,因为每次换手势才需要一次按键。如果同一个手势要连续触发多次(比如比 3 连续翻多页),冷却时间方案更合适。

5.4 坑四:mask 窗口全是黑的

现象:按 b 键之后,mask 窗口没有任何白色区域,binary 窗口里也看不到手。

原因:多半是背景建模时手已经在画面里。MOG2 建模时如果手是静止的,它会被当成背景的一部分;你把手拿走再比手势,背景模型里那个位置是手的灰度值,而新进来的手也是那个灰度值,差分结果为零。另一个常见原因是cap_region_x_begin=0.5,你的手在画面左侧,压根不在检测区域内。

解决:第一步,按 b 之前把手从摄像头画面里移开,等 1 秒再按;第二步,确认手在画面右侧的红色矩形框内;第三步,如果还是黑的,按 r 重置再重新按 b,多试两次。还不行就把 threshold 滑条往左拖,降低到 30 左右,让更多的灰度差异被识别为前景。

5.5 坑五:同事的电脑上跑不起来

现象:代码在自己的机器上一切正常,换一台电脑报错ModuleNotFoundError: No module named 'win32api',或者摄像头一直黑屏。

原因:win32api 是 pywin32 库提供的,不是 Python 标准库,也不是 OpenCV 自带的。pip install pywin32不装,这个程序在 Windows 上就跑不了。摄像头黑屏则可能是电脑自带摄像头被占用,比如 Zoom、微信等软件正在使用摄像头,VideoCapture(0)抢不到资源。

解决:在项目文件头部加一个依赖检查:

try: import win32api import win32con except ImportError: raise SystemExit("Missing pywin32, run: pip install pywin32")

摄像头被占用的问题,关掉所有可能占用摄像头的软件,然后重启 Python 进程。如果你用的是外接摄像头,把VideoCapture(0)改成VideoCapture(1),设备索引从 0 开始,0 通常是内置摄像头,1 是第一个外接摄像头。设备忙时 OpenCV 不会报错,只是ret为 False,可以在camera.read()后面加个判断:

ret, frame = camera.read() if not ret: print("Failed to read from camera, check if it is occupied") continue

6. 让指尖检测更准的验证方法:单步回放与边界数据

整个程序最让我头疼的不是算法本身,而是「感觉不对但说不清哪里不对」。后来我养成一个习惯:不管项目多小,都要给自己留一条单步验证的路。具体做法是把摄像头输入换成本地视频或图片序列,这样才能复现同一组手势。做法很简单,把camera = cv2.VideoCapture(0)换成camera = cv2.VideoCapture("test.avi"),代码其他部分不用改。本地视频不受光线、手的位置影响,同一帧跑到那里,检测结果应该完全相同。如果两次跑同一帧结果不一样,那一定是程序里有隐式状态泄漏。

验证手指数目有个更系统的办法:录制 5 段短视频,每段固定比 1 到 5 根手指,每段 10 秒。跑完程序后统计每帧输出的 cnt 分布,看这 10 秒内 dominant(出现最多的手指数)是不是和手势一致。比如比 3 的手指,理想输出是大部分帧 cnt 等于 3,偶尔跳到 4 或 2 属于正常,如果超过一半的帧都不在 3 上,就要回头调 threshold 或者 blurValue。我一般会用一个小脚本统计:

from collections import Counter counts = Counter(all_cnts) total_frames = len(all_cnts) accuracy = counts[expected_fingers] / total_frames print(f"accuracy: {accuracy:.2%}")

把准确率跟 80% 这个基准比:高于 80%,这个手势可以实际使用;低于 80%,先别急着上键盘模拟,回去调参。影响准确率的因素按权重排序,第一是光照,(环境一致的条件下能显著提精度);第二是 blurValue,核太大会把相邻手指的边缘糊在一起;第三是 threshold,它决定手指边缘的完整度;第四才是距离法的 count 参数。

另一个简洁有效的验证法是画轮廓点轨迹。把每一帧的指尖坐标存进列表,跑完一组手势后一次性画出来,你会直观地看到指尖检测的稳定性。指尖应该是几个清晰的簇,而不是一大片散点:

import matplotlib.pyplot as plt xs = [p[0] for p in tip_history] ys = [p[1] for p in tip_history] plt.scatter(xs, ys, s=1) plt.show()

指尖坐标如果散得像噪点,说明轮廓不稳定,优先检查高斯模糊核和 MOG2 的前景阈值,而不是去调指尖检测逻辑。如果簇很清晰但位置漂移,那多半是摄像头自动白平衡在起作用,可以考虑用camera.set(cv2.CAP_PROP_WHITE_BALANCE_BLUE_U, 0)禁用自动白平衡。

从那以后,我每次拿到这类视觉检测项目,都会强制自己先录一段固定视频再跑,不把时间浪费在「这次手放的位置跟上次不一样」这种不可控变量上。这个方法帮我在这个项目里至少省出了半天调试时间,希望你也能用它少走弯路。希望帮到你。

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

返回列表