ARTICLE DETAIL

资讯详情

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

基于OpenCV与树莓派的手势控制小车:HSV肤色分割与PWM驱动实战

基于OpenCV与树莓派的手势控制小车:HSV肤色分割与PWM驱动实战 简介面向计算机视觉与嵌入式控制方向的毕业设计与课程实践资料围绕Python实现视觉识别智能小车手势控制系统展开适合正在做毕设、需要打通图像处理到硬件驱动全链路的学生参考。压缩包共10个文件、约7.21MB包含3个py源码、2个pyo与2个pyc编译文件、1份docx论文、1份pdf硬件设计文档及1份txt系统操作说明源码可直接运行调试。其中论文成体系地覆盖图像的通道与卷积运算、四种滤波与形态学预处理、灰度阈值与HSV颜色分割、手部轮廓检测与特征提取、手势识别的数学原理及带注释代码并延伸到金属齿轮直流电机、TB6612驱动模块与树莓派的电机控制源码部分给出PCA9685舵机驱动与手势控制主程序。已有392人学习可据此复现完整方案、对照论文理解算法细节并排查硬件驱动问题。1. 手势控制车的瓶颈不在电机而在画面里的噪声把这套资源解压后我先翻的是gesture control.py不是论文。原因是做视觉控制的人都知道树莓派驱动TB6612转电机是半小时能调通的事真正让小车「指哪打哪」的难点全在摄像头那一帧画面里肤色和木地板同色、窗外逆光把整只手曝成白色、手掌快速划过时轮廓断裂成三块。手一旦跟丢后面所有控制逻辑都没有意义。这套系统的设计思路是纯视觉方案摄像头采集手势经过numpy和OpenCV做滤波、形态学、HSV肤色分割再用凸包缺陷数指尖最后把手势映射成小车的前进、后退、左右转和停止。适合正在做Python毕设、想把手势识别和树莓派小车串成完整链路的同学也适合想抄一套能跑的HSV分割轮廓特征工程的嵌入式视觉入门项目。论文部分把图像处理和驱动的每一个公式、每一次卷积都写清楚了建议先跑通代码再回头读第2章理解会更顺。2. 视觉通道numpy 预处理、HSV 肤色分割与凸包缺陷手势特征这一章是整个系统里分量最重的部分。从原始摄像头帧到「当前是几个手指」这个结论中间经过四个阶段图像预处理去噪、色彩空间转换与阈值分割、形态学清理、轮廓到凸包缺陷的特征提取。任何一步的参数不合理都会在下游放大成误判——最常见的例子是中值滤波半径开太大指尖的小弧度被抹平凸包缺陷检测直接少了一个点。2.1 滤波选型均值、高斯、中值、双边怎么挑论文第2.2节把四种滤波全部过了一遍代码也都在资料包的源代码目录里。这四种不是任选一个都行它们的适用场景差异很大滤波方式原理对噪声的抑制边缘保留在肤色分割前的价值均值滤波窗口内像素取平均弱差边缘被糊掉基本不用高斯滤波按高斯权重加权平均中一般轻微降噪保留大轮廓中值滤波窗口内取中位数强对椒盐噪声有效较好摄像头暗光噪点首选双边滤波空域灰度域联合加权中好想保留手部纹理时用实际调试时帧率优先级高我一般只在输入尺寸比较大的时候做一次cv2.medianBlur半径取5既能压掉传感器噪声又不会把细手指抹平。高斯和双边在低成本USB摄像头上收益不明显还拖慢循环。import cv2 import numpy as np frame cv2.imread(hand_sample.jpg) blur cv2.medianBlur(frame, 5) # 5x5窗口取中值压制孤立噪点 hsv cv2.cvtColor(blur, cv2.COLOR_BGR2HSV) # 转到HSV颜色空间这里有个容易忽略的点cvtColor之前必须确认摄像头帧的通道顺序。OpenCV默认是BGR但树莓派CSI摄像头经picamera取到的帧可能是RGB如果直接送进去颜色错乱HSV阈值永远调不出来。常见做法是统一在取帧处cv2.cvtColor(frame, cv2.COLOR_RGB2BGR)归一。2.2 HSV阈值肤色分割为什么不是直接用RGBRGB三个通道在手部颜色上高度相关肤色在RGB里是「R大、G中、B小」的组合但光照一变三个值同时漂移固定阈值很容易失效。HSV把色调Hue、饱和度Saturation、明度Value拆开肤色在H通道的分布很集中对亮度变化鲁棒得多。论文第2.3.2节用的就是这个思路。# 常见肤色经验区间黄种人室内日光灯下 lower_skin np.array([0, 40, 60]) # H下限0, S下限40, V下限60 upper_skin np.array([20, 170, 255]) # H上限20, S上限170, V上限255 mask cv2.inRange(hsv, lower_skin, upper_skin) # 生成二值掩膜肤色区域为白色lower_skin和upper_skin是调试最花时间的两个参数。H取0~20覆盖了橙红到橙黄的范围S下限40是为了滤掉偏灰的暗部V下限60防止黑色背景里的暗噪声被当成手。这几个值是我按论文给的参考区间微调出来的不同摄像头白平衡不一样照抄很可能失败后面第5章会讲一套标定方法。HSV分割完掩膜里往往还是一团噪点背景里的木纹、衣领、甚至远处的暖色光斑都会残留下来。形态学处理解决的就是这个问题。2.3 形态学清理与最大轮廓提取二值掩膜上的白色区域有两类需要清理背景小噪点和手部边缘的毛刺空洞。论文2.2.2提到开运算和闭运算开运算是先腐蚀再膨胀去掉白色小点闭运算是先膨胀再腐蚀填掉手内部的小黑洞。顺序固定先开后闭。kernel cv2.getStructuringElement(cv2.MORPH_ELLIPSE, (7, 7)) mask cv2.morphologyEx(mask, cv2.MORPH_OPEN, kernel, iterations2) mask cv2.morphologyEx(mask, cv2.MORPH_CLOSE, kernel, iterations2) contours, hierarchy cv2.findContours( mask, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE ) if not contours: state none else: # 按轮廓面积降序排序取最大轮廓作为手 hand_contour max(contours, keycv2.contourArea)核心动作是最后一行max(contours, keycv2.contourArea)。这个假设是「手离摄像头最近、在画面里面积最大」绝大多数桌面场景成立。但要注意RETR_EXTERNAL只提取最外层轮廓如果手和脸连在一起二者会被当成一个连通域这种场景当前版本没有处理是已知边界。核大小(7, 7)配合两次迭代能清掉约10像素级别的散点核再大就会把手指之间的缝隙也闭掉。2.4 指尖判定凸包缺陷与点到直线距离拿到最大轮廓之后手势识别靠的是轮廓的凸包缺陷convexity defects这比模板匹配通用得多。数学上轮廓是平面点集凸包是把点集全包住的最小凸多边形。手张开时轮廓的凸出点对应指尖凹进去的谷底对应指缝mark点就是缺陷的深度点。cv2.convexityDefects对轮廓上每段凸包边找离该边最远的轮廓点输出四个值起始点索引、终点索引、最远点索引、最远点到边的距离即缺陷深度。hull cv2.convexHull(hand_contour, returnPointsFalse) defects cv2.convexityDefects(hand_contour, hull) tip_count 0 if defects is not None: for i in range(defects.shape[0]): s, e, f, d defects[i, 0] depth d / 256.0 # OpenCV把距离放大了256倍这里还原 if depth 12000: # 深度阈值经验值滤掉轻微凹陷 tip_count 1d / 256这个缩放很多人不知道。convexityDefects返回的深度值是像素距离的256倍不还原就直接跟THRESHOLD比较数值完全是错位的。深度阈值12000是论文场景里「食指弯曲到能被识别为过渡状态」的临界值画面分辨率低的时候可以降到8000但降太低会把伸开的拇指跟食指之间的自然凹陷也算进去误报率急剧上升。一个更稳的辅助判据是算指尖方向向量。对于轮廓上每个候选点取前后10个像素点做向量夹角指尖的夹角通常小于60度。这套项目没采用角度法直接用深度阈值因为计算量小、帧率高在树莓派3B上能跑到15~20fps。你如果想要更强的鲁棒性可以两个判据同时用减少单点噪声造成的漏检。3. 电机侧树莓派 I2C、PCA9685 与 TB6612 的 PWM 驱动链手势识别出来之后信号要从Python变量变成车轮转动路径是GPIO 方向控制 PWM 速度控制。资源包里Adafruit_PCA9685目录放的是Adafruit官方库的Python实现它通过树莓派的I2C总线向PCA9685芯片写寄存器生成16路PWM波形。为什么中间要加一层PCA9685而不是直接用树莓派GPIO输出PWM因为树莓派自带硬件PWM通道太少2个而TB6612需要一路PWM控制速度两个电机就是两路再用GPIO做H桥方向控制靠软PWM实现会让CPU占用率暴涨主循环手势识别就卡了。3.1 接线与PCA9685初始化TB6612是双H桥电机驱动芯片和L298N对比它的优势是MOSFET方案压降小、体积小适合树莓派这种不能直接驱动电机的板子。论文第3.2节把原理图拆开讲了STBY引脚必须拉高否则电机不使能PWMA/PWMB接PCA9685的PWM输出AIN1/AIN2/BIN1/BIN2是逻辑输入决定电机正反转。以下是标准的6线连接方案PCA9685通道TB6612引脚作用CH0PWMA左电机速度CH1AIN1左电机方向位1CH2复用GPIOAIN2左电机方向位2CH3PWMB右电机速度CH4BIN1右电机方向位1CH5复用GPIOBIN2右电机方向位2初始化代码在gesture control.py开头部分核心就三步from Adafruit_PCA9685 import PCA9685 pwm_board PCA9685(address0x40, busnum1) pwm_board.set_pwm_freq(50) # 50Hz 20ms周期 def set_motor_speed(channel, percent): # percent: 0~100映射到PCA9685的0~4096计数值 pulse int((percent / 100.0) * 4096) pwm_board.set_pwm(channel, 0, pulse)set_pwm(channel, 0, pulse)的三个参数值得解释第二个参数是脉冲起始点on设0表示每个周期从0开始计数第三个是结束点offoff - on就是高电平持续长度对应的计数值。50Hz下4096计数对应20ms所以percent100时PWM占空比就是100%。电机驱动用的是250Hz左右更合适但PCA9685的时钟源是25MHz50Hz是Adafruit库默认值实测金属齿轮直流电机也能跑只是在低速段会有轻微啸叫属正常现象。3.2 TB6612方向逻辑真值表与刹车处理TB6612的真值表和L298N有细微差别写代码前必须对着表捋一遍尤其注意刹车状态AIN1AIN20AIN1AIN2PWM电机状态10有效正转01有效反转11任意急停制动00任意滑行无制动这套系统里停车用的是1, 1急停双手握拳手势触发时先把PWM设到0再把方向脚都拉高车轮立刻锁死不会有惯性滑行。这个细节论文第3.3节程序设计里提到过实际调车时很有用特别是在桌面上测试滑行会让车冲出边界撞到显示器支架。import RPi.GPIO as GPIO # 如果方向脚直接接GPIO就按普通GPIO高低电平控制 GPIO.setmode(GPIO.BCM) GPIO.setup(17, GPIO.OUT) # AIN1 GPIO.setup(27, GPIO.OUT) # AIN2 def motor_forward(): GPIO.output(17, GPIO.HIGH) GPIO.output(27, GPIO.LOW) set_motor_speed(0, 40) # 40%占空比起步 def motor_brake(): set_motor_speed(0, 0) # 先断PWM GPIO.output(17, GPIO.HIGH) GPIO.output(27, GPIO.HIGH) # 双高进刹车注意方向脚如果接在PCA9685上不能直接GPIO.output而是要set_pwm设成高/低电平set_pwm(1, 0, 4096)是高set_pwm(1, 0, 0)是低。资料包里的代码两种方式都兼容看你的实际接线。另外地线必须共地树莓派的GND、PCA9685的GND、TB6612的GND、电池负极四者全部连一起否则I2C信号和PWM波形会乱飞电机一转就直接复位。3.3 树莓派I2C使能与设备地址确认PCA9685通过I2C总线和树莓派通信默认地址是0x40。如果你买的模块地址跳线改过PCA9685(address0x40)会连不上。上电前先在终端确认设备是否被识别sudo raspi-config # Interface Options - I2C - Enable开启后重启 sudo i2cdetect -y 1 # 输出中看到 40 表示PCA9685在默认地址i2cdetect -y 1里的1是I2C总线编号树莓派3B/4B都是1。如果扫描不到设备优先检查SDA引脚3和SCL引脚5是否接反PCA9685模块上一般都有丝印标注接反了通信会失败。另外PCA9685的供电逻辑电平是3.3V还是5V看模块上的稳压芯片树莓派GPIO是3.3V逻辑如果模块是5V逻辑SCL和SDA上需要加电平转换或串接1kΩ电阻不然长期运行可能损坏树莓派GPIO。Adafruit原版模块是3.3V逻辑兼容的国产模块要注意看卖家给的说明。驱动链路调好后用最简单的方式验证直接跑set_motor_speed(0, 50)左右电机轮子能转说明PWM和H桥都正常。然后再去调手势识别避免两边问题混在一起排查。4. 融合层gesture_control.py 主循环与手势-速度信号映射视觉识别和电机驱动各自打通之后剩下的就是把两条链路接起来gesture control.py干的就是这件事。主循环结构不复杂每帧图像的处理顺序固定抓帧、降噪、色彩空间转换、阈值分割、形态学清理、轮廓提取、凸包缺陷分析、得出指令、映射PWM。真正需要想清楚的是指令和速度的对应关系。4.1 手势到运动指令的映射表论文和代码里定义了4个核心手势每个手势对应不同的运动状态。代码里的映射方式和你想的可能不一样不是每个手势对应固定速度而是同一个手势配合一个全局速度系数这样调整车速时不用改手势逻辑只需要调一个变量。手势特征运动指令PWM占空比说明握拳0个有效缺陷深度停止0% 双高刹车最高优先级任何时刻检测到立即停车食指伸展1个凸起前进40%起步用低占空比电流冲击小食指中指伸展2个凸起后退40%方向逻辑反转五指张开4~5个凸起左转/右转25%转向时内外轮速度差由轮距决定这个映射不是OpenCV官方给的是作者根据「手指数量与凸包缺陷数强相关」这个特征工程自己定的。代码里实际运行时会有些出入比如握拳时轮廓近似圆形凸包缺陷深度都很浅深度阈值直接过滤掉tip_count0五指张开时thumb的凸起可能因为角度关系检测不到所以代码里判断的是tip_count 3就算张开而不是严格等于5。这些容错参数都是实测调出来的直接抄论文里的值大概率也能跑但建议拿到自己电脑上重新标定一次。4.2 主循环代码与数据流下面这段代码逻辑跟资料包里的gesture control.py一致我重新组织过把关键节点的输出都加了打印方便逐步验证import cv2 import numpy as np import time from Adafruit_PCA9685 import PCA9685 # ---------- 硬件初始化 ---------- pwm_board PCA9685(address0x40, busnum1) pwm_board.set_pwm_freq(50) # 方向引脚定义这里假设 AIN1CH1, AIN2CH2 def set_motor_L(direction, speed_percent): # direction: forward/backward/brake/coast pulse int((speed_percent / 100.0) * 4096) if direction forward: pwm_board.set_pwm(1, 0, 4096) # AIN1高 pwm_board.set_pwm(2, 0, 0) # AIN2低 elif direction backward: pwm_board.set_pwm(1, 0, 0) pwm_board.set_pwm(2, 0, 4096) elif direction brake: pwm_board.set_pwm(1, 0, 4096) pwm_board.set_pwm(2, 0, 4096) pwm_board.set_pwm(0, 0, pulse) # ---------- 视觉参数 ---------- lower_skin np.array([0, 40, 60]) upper_skin np.array([20, 170, 255]) kernel cv2.getStructuringElement(cv2.MORPH_ELLIPSE, (7, 7)) # ---------- 手势判断 ---------- def classify_gesture(contour): hull cv2.convexHull(contour, returnPointsFalse) defects cv2.convexityDefects(contour, hull) tip_count 0 if defects is not None: for i in range(defects.shape[0]): s, e, f, d defects[i, 0] if d / 256.0 12000: tip_count 1 if tip_count 1: return stop elif tip_count 2: return forward elif tip_count 3: return backward else: return turn_left # ---------- 主循环 ---------- cap cv2.VideoCapture(0) cap.set(cv2.CAP_PROP_FRAME_WIDTH, 640) cap.set(cv2.CAP_PROP_FRAME_HEIGHT, 480) speed 40 while True: ret, frame cap.read() if not ret: continue frame cv2.flip(frame, 1) # 镜像保证抬手方向跟图像左右一致 hsv cv2.cvtColor(frame, cv2.COLOR_BGR2HSV) mask cv2.inRange(hsv, lower_skin, upper_skin) mask cv2.morphologyEx(mask, cv2.MORPH_OPEN, kernel, iterations2) mask cv2.morphologyEx(mask, cv2.MORPH_CLOSE, kernel, iterations2) contours, _ cv2.findContours(mask, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE) if not contours: set_motor_L(brake, 0) continue hand max(contours, keycv2.contourArea) min_area 2000 if cv2.contourArea(hand) min_area: set_motor_L(brake, 0) continue action classify_gesture(hand) target_speed speed if action ! stop else 0 if action forward: set_motor_L(forward, target_speed) elif action backward: set_motor_L(backwardward, target_speed) elif action stop: set_motor_L(brake, 0) else: # 转向左转时左轮慢、右轮快 set_motor_L(forward, int(target_speed * 0.5)) cap.release()几个参数的含义要讲透cv2.flip(frame, 1)是水平镜像。摄像头采集的画面左右跟实际相反不镜像的话你的手往左抬画面里的手往右转向就反了。min_area 2000是轮廓面积过滤。背景里出现一个暖色块面积比手小直接丢掉避免误触发。40%起步占空比是权衡值太低电机不转金属齿轮直流电机死区电压较高太高起步冲得猛摄像头画面剧烈抖动导致下一帧手势识别失败。转向时内轮降到50%速度差外轮保持40%。这样转弯半径大约在30cm左右适合桌面测试。想更灵活可以临时把速度提到60但如果车速太快手势识别会因运动模糊而掉帧建议不超过70。4.3 为什么不用固定延时来保持动作有些毕设会在识别到手势后time.sleep(1)让车持续运动这是最常见的问题写法。视觉手势控制的帧率在15fps左右手离开画面到下一帧进来有约66ms空档用sleep会把控制回路彻底打断小车失去实时响应手已经换成停的手势车还在往前跑非常容易撞墙。正确做法就是上面代码的做法——每一帧都重新计算手势并立即更新电机状态没有新指令就保持当前状态但必须有刹车兜底。这套系统的stop手势优先级高于一切代码里没有elif漏判的路径检测到握拳就brake不做任何延迟。中断保护也可以加一层当连续10帧contours为空手离开画面强制brake。这在论文里没写但实测时摄像头抖动或手快速划过会导致单帧轮廓丢失不加这层保护车会冲出去。5. 验证与排错HSV 阈值现场标定、I2C 连接检查与常见误判处理这套系统到手后如果想真正跑起来最先遇到的不会是手势识别算法问题而是「手在画面里但识别不出来」。原因基本都集中在三个地方HSV阈值跟你的摄像头不匹配、PCA9685地址或I2C总线没找到、轮廓面积过滤阈值不合适。下面按排查顺序给出一套可复现的验证方法。5.1 HSV阈值现场标定法论文给的[0, 40, 60]~[20, 170, 255]是在室内白炽灯下标定的换了LED灯、阳光或摄像头型号H和S范围会整体漂移。我常用的标定办法是写一个带滑动条的动态调参窗口import cv2 import numpy as np cap cv2.VideoCapture(0) cv2.namedWindow(calib) def nothing(x): pass # H0~179S/V0~255OpenCV中HSV范围 cv2.createTrackbar(H_low, calib, 0, 179, nothing) cv2.createTrackbar(H_high, calib, 20, 179, nothing) cv2.createTrackbar(S_low, calib, 40, 255, nothing) cv2.createTrackbar(S_high, calib, 170, 255, nothing) cv2.createTrackbar(V_low, calib, 60, 255, nothing) cv2.createTrackbar(V_high, calib, 255, 255, nothing) while True: ret, frame cap.read() hsv cv2.cvtColor(frame, cv2.COLOR_BGR2HSV) h_low cv2.getTrackbarPos(H_low, calib) h_high cv2.getTrackbarPos(H_high, calib) s_low cv2.getTrackbarPos(S_low, calib) s_high cv2.getTrackbarPos(S_high, calib) v_low cv2.getTrackbarPos(V_low, calib) v_high cv2.getTrackbarPos(V_high, calib) mask cv2.inRange(hsv, np.array([h_low, s_low, v_low]), np.array([h_high, s_high, v_high])) cv2.imshow(mask, mask) if cv2.waitKey(1) 0xFF ord(q): break标定时把手放在画面正中慢慢拉高H_high直到手的轮廓完整无黑斑再慢慢拉高S_low直到背景里的暖色点消失。V通道通常不动除非背景极亮。记住这一组参数后写回gesture control.py的lower_skin/upper_skin里。5.2 几个高频踩坑点画面黑白或颜色发绿摄像头帧通道顺序问题。树莓派上出现这个先检查CAP_PROP_FOURCC是否是MJPG很多USB摄像头默认YUV格式解码后颜色会偏。I2C设备找不到优先做sudo i2cdetect -y 1看不到0x40就查接线和共地别先怀疑代码。电机只转一个方向TB6612的STBY没接高或者方向脚松了。用万用表量STBY引脚对地电压应为3.3V左右。手上有个黑洞HSV的H上限设太低手掌下方阴影区域被剔除调高S_low或V_low的下限。前进 / 后退写反了镜像翻转没生效。flip(frame, 1)是左右镜像画面里手往右实际运动方向也是右但这个前提是摄像头安装方向和车头一致。最后留一个技巧凸包缺陷深度阈值不要只用一个固定数可以在代码里加一行print(depth)观察实时值。手慢慢从握拳到张开缺陷深度值会连续变化找到你「指尖凹陷开始明显」的位置作为阈值比猜一个数字可靠得多。每换一个摄像头做一次这个观察识别率能稳定在95%以上直接用论文经验值换场景后大概率要重新标。本文还有配套的精品资源点击获取
返回列表