资讯动态

基于傅里叶描述子与SVM的Python实时手势识别实战

发布时间:2026/10/29 11:21:47 来源:尧图企业网站定制
简介这是一份面向机器学习与计算机视觉学习者的手势识别完整项目源码包围绕傅里叶算子在手势轮廓特征提取中的应用展开适合课程设计、毕业设计及算法入门实践。包内共2000个文件以txt说明与样本标注、png手势图像、m脚本、py源码及少量pyc、xml、docx文档为主压缩包约142.52MB样本库、训练模型与界面程序相对齐全。项目覆盖图像平滑、OTSU阈值肤色分割、八邻域轮廓搜索以及傅里叶描述子和椭圆傅里叶描述子的提取与归一化并用KNN、SVM完成训练对比最后提供基于PyQt5的简易交互界面。已有13878人学习下载读者可据此复现从采集图像到轮廓曲线、特征构造、模型训练与可视化演示的完整流程并参考已有样本和脚本排查环境配置、参数调整与分类效果问题。1. 摄像头前比个「OK」标签就出来了摄像头对着自己的手比一个「OK」屏幕上跳出 ok换成握拳标签翻成 fist。整套逻辑跑在一台没有独显的笔记本上单帧处理不到 20 毫秒。这份基于傅里叶算子的手势识别完整源代码Python 实现附带样本库把采集、预处理、形状特征提取到 SVM 分类的每一段都摊开写了没有黑盒模型也没有几百 MB 的权重文件。它适合三类人做课程设计需要一个能讲清原理又不烧显卡的方案想在树莓派一类的板子上跑实时识别以及已经会用深度学习调包、但想弄明白「形状描述子」到底怎么把一条轮廓变成特征向量的开发者。读完你能把它跑起来也能按自己的手势类别重新训一版。2. 技术选型与工程架构傅里叶描述子加 SVM 的完整链路动手之前先把这套组合的账算清楚。手势识别这个题目绝大多数人第一反应是上卷积网络但卷积网络的门槛不在模型本身在数据——每个类别没有几百上千张带变化的样本训出来的东西很容易变成「只认训练时那个背景」。傅里叶描述子走的是另一条路它不学纹理只描述轮廓形状把一条二维闭合曲线变换成一串频域系数低频管整体形状高频管指尖的细节。手部轮廓恰好是这类特征的理想对象握拳、伸掌、比数字差异全在轮廓的凹凸结构上。2.1 为什么不用 CNN小样本与算力账把两种方案的工程量摆在一起看更直观。CNN 路线要做的是收集几千张图、标注、数据增强、划分训练验证、调学习率、处理过拟合、导出模型、部署推理框架。FD SVM 路线要做的是采集几百帧、抠轮廓、算特征、训一个几十 KB 的模型、joblib 一 dump 就完事。前者精度上限高后者在「类别少、背景可控、样本自己采」的场景里性价比高得多。代价也很明确得提前说清楚傅里叶描述子只用形状丢了颜色和纹理所以它天然区分不了「同样是五指张开、但手掌朝向不同」这类手势取幅度谱之后连镜像也分不出来左手比的手势和右手比的可能归到同一类。如果你的需求是复杂手势、连续动作、大词汇量这套方案不合适别硬上。反过来如果是五到十个静态手势、固定机位、室内光照它能做到几百帧样本就出结果而且每一维特征都可解释出问题能查——这一点在做课程设计答辩或者给非算法同事讲原理时特别值钱。SVM 在这里的角色是「小样本分类器」。特征只有二十来维样本量几百到几千这个比例下线性核和 RBF 核都能收敛得很稳而且 SVM 的决策边界由支持向量决定样本少的时候比随机森林更不容易被噪声带偏。常见的做法是 RBF 核加网格搜索把 C 和 gamma 搜一遍再用宏平均 F1 选最优避免类别不平衡时被多数类刷分。2.2 从一帧画面到分类标签的四段流水线整条链路可以切成四段每段职责单一方便单独替换采集从摄像头或视频文件读帧同时把带标签的原始帧按类别存盘这是样本库的源头。预处理颜色空间转换 → 肤色掩膜 → 形态学修补 → 取最大外轮廓输出一条点序列。特征提取轮廓等弧长重采样到固定点数 → 复数编码 → FFT → 取低频幅度谱并归一化输出定长向量。分类标准化 SVM 预测实时场景再加一层滑窗投票压抖动。这四段之间用 numpy 数组衔接没有隐式的全局状态所以你可以只换第三段比如改用 Hu 矩后面训练脚本一行不改。这也是我倾向于把预处理和特征提取拆成两个文件的原因——调试的时候能单独把中间结果存成图片看比在整条链路里打断点快得多。2.3 样本库与目录结构源包里的目录组织大致是这样训练脚本和识别脚本都按这个约定找文件gesture-fourier/ ├── data/ │ ├── raw/ # 原始采集帧一个类别一个子目录 │ │ ├── fist/ │ │ ├── palm/ │ │ └── ok/ │ └── features/ # 提取后的特征矩阵与标签 │ ├── X.npy │ └── y.npy ├── src/ │ ├── config.py # 阈值、路径、类别名集中放这里 │ ├── capture.py # 采集脚本按空格存帧 │ ├── preprocess.py # 肤色分割与轮廓提取 │ ├── fourier_descriptor.py # 重采样与 FD 计算 │ ├── build_dataset.py # 批量提特征落 X.npy / y.npy │ ├── train_svm.py # 网格搜索与模型导出 │ └── recognize.py # 实时识别主循环 ├── models/ │ ├── svm.pkl │ └── scaler.pkl └── requirements.txt依赖不多装起来不费劲包用途备注opencv-python读帧、颜色空间、轮廓、形态学4.x 均可numpy重采样、FFT、矩阵运算建议 1.20 以上scikit-learnSVM、标准化、网格搜索提供 Pipeline 与 joblibjoblib模型序列化sklearn 已自带依赖config.py里集中放阈值这件事看着不起眼但它是后期调参的救命稻草。肤色阈值、最小轮廓面积、重采样点数、描述子维数全部提成常量在识别脚本里用同一个 config训练和推理才不会出现「训练用 128 点、推理用 64 点」这种对不上的情况。我见过太多项目把参数散落在各个文件里改一个忘一个排查起来非常痛苦。3. 特征提取链路从肤色掩膜到定长傅里叶描述子这一段是整份代码的核心也是最容易翻车的地方。输入是一帧 BGR 图像输出是一个 20 维的 float32 向量。中间有四步每一步都能单独拿出来验证。3.1 肤色分割与掩膜生成单纯用 HSV 做肤色分割在室内灯光下很容易碎裂代码里用的是 YCrCb 加 HSV 双空间求交集的办法两个空间都认为是肤色的像素才保留import cv2 import numpy as np def hand_mask(bgr, hsv_lower(0, 30, 60), hsv_upper(25, 180, 255)): 双颜色空间求交返回单通道 0/255 掩膜 ycrcb cv2.cvtColor(bgr, cv2.COLOR_BGR2YCrCb) hsv cv2.cvtColor(bgr, cv2.COLOR_BGR2HSV) # YCrCb 的经典肤色区间Cr 133~173Cb 77~127 m1 cv2.inRange(ycrcb, np.array([0, 133, 77]), np.array([255, 173, 127])) m2 cv2.inRange(hsv, np.array(hsv_lower), np.array(hsv_upper)) return cv2.bitwise_and(m1, m2)逻辑上YCrCb 的 Cr 通道对肤色响应强、对亮度不敏感是分割的主力HSV 的 H 通道负责把偏红偏黄的干扰木质桌面、纸箱挡掉。两者求交是「宁可少留不可多留」因为后面的最大轮廓筛选会丢掉零碎区域但多出来的一大块非手区域是丢不掉的。几个参数值得说Cr 的上下界 133 和 173 是通用值实际用的时候最好拿自己的手在镜头前跑一遍看掩膜覆盖得全不全偏了就上下挪 5 到 10。HSV 的 S 下界 30 用来过滤灰白色墙壁、白纸如果你的背景是深色桌面可以放到 20V 下界 60 用来挡阴影光线暗的环境要往下调。这些都写进 config别硬编码在函数里。3.2 最大轮廓筛选与形态学修补原始掩膜出来之后一定是有毛刺和空洞的直接取轮廓会得到一条锯齿状的曲线傅里叶高频分量会被噪声主导。所以中间要插一步形态学处理def clean_and_extract(bgr, min_area3000): 掩膜清理 最大外轮廓提取失败返回 None mask hand_mask(bgr) # 椭圆核比矩形核更贴合手部边缘核大小随分辨率调整 k cv2.getStructuringElement(cv2.MORPH_ELLIPSE, (7, 7)) mask cv2.morphologyEx(mask, cv2.MORPH_OPEN, k) # 去掉零星噪点 mask cv2.morphologyEx(mask, cv2.MORPH_CLOSE, k) # 补上掌心的空洞 mask cv2.medianBlur(mask, 5) # 抹平边缘毛刺 cnts, _ cv2.findContours(mask, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_NONE) if not cnts: return None, mask c max(cnts, keycv2.contourArea) # 面积门槛挡住「画面里没人手」的帧避免把噪点当手 if cv2.contourArea(c) min_area: return None, mask return c, maskMORPH_OPEN先腐蚀后膨胀专治孤立噪点MORPH_CLOSE先膨胀后腐蚀专治掌心因为反光造成的空洞。顺序不能反反过来会把噪点也一起闭合进去。medianBlur用中值而不是高斯是因为中值滤波对边缘的保留更好高斯会把指尖磨圆直接影响描述子里对应指尖的高频分量。CHAIN_APPROX_NONE表示存储轮廓上的每一个像素点不做压缩。这是有意的后面的等弧长重采样需要足够密的点才能保证插值精度如果用了CHAIN_APPROX_SIMPLE直线段会被压成两个端点重采样出来的形状就不对了。min_area这个门槛在 640×480 分辨率下取 3000 左右比较合适手掌在画面里的占比大概三成。如果用户坐得远手只占一小块这个值要往下调但同时误检率会上升通常的做法是反过来——固定一个 ROI 方框让用户把手放进去从工程上消灭这个问题。3.3 轮廓重采样到定长点集findContours给出的点数是浮动的手离镜头近一点轮廓周长变长点数就有七八百远一点可能只有三百。傅里叶变换要求输入序列长度固定所以必须先重采样。这里用的是等弧长插值沿轮廓周长均匀取 N 个点def resample_contour(contour, num_points128): 沿轮廓周长等弧长采样返回 (num_points, 2) 的 float64 数组 pts contour.reshape(-1, 2).astype(np.float64) pts np.vstack([pts, pts[:1]]) # 首尾相接闭合 seg np.sqrt(((pts[1:] - pts[:-1]) ** 2).sum(axis1)) cum np.concatenate([[0.0], np.cumsum(seg)]) total cum[-1] # 目标采样位置不包含终点避免与起点重合 s np.linspace(0.0, total, num_points, endpointFalse) # 定位每个采样点落在哪一段并在段内线性插值 idx np.clip(np.searchsorted(cum, s, sideright) - 1, 0, len(seg) - 1) t (s - cum[idx]) / (seg[idx] 1e-9) out pts[idx] t[:, None] * (pts[idx 1] - pts[idx]) return outnum_points取 128 是个经验值。太小比如 32会把手指之间的凹槽抹平不同手势区分不开太大512 以上对精度没有实质提升反而让 FFT 变慢而且高频段全是噪声。128 点对应到 640×480 的画面指尖的圆角大概能占到 3 到 5 个采样点够用了。这段代码全部向量化没有 Python 层循环128 点的重采样在普通 i5 上大概几十微秒。searchsorted用的是sideright配合减一保证落在段起点时取到正确的区间分母加1e-9是防止退化轮廓比如手完全静止被压成一条线出现除零。3.4 复数编码、FFT 与频谱归一化真正把轮廓变成特征的一步。把重采样后的点看成复平面上的序列 z x iy做 FFT然后取低频部分的幅度谱def fourier_descriptor(contour, num_points128, num_coeffs20): 轮廓 - 定长傅里叶描述子返回 (num_coeffs,) 的 float32 pts resample_contour(contour, num_points) z pts[:, 0] 1j * pts[:, 1] z z - z.mean() # 去质心平移不变 F np.fft.fft(z) # 丢掉 DC 分量 F[0]只含位置信息取前 num_coeffs 个低频 mag np.abs(F[1:num_coeffs 1]) mag mag / (mag[0] 1e-8) # 以基频归一尺度不变 return mag.astype(np.float32)三处设计各对应一个不变性。去质心处理平移手在画面左边还是右边减掉均值之后序列完全一样。取幅度谱丢相位同时解决旋转和起点问题轮廓旋转 θ 等价于复数序列乘以 e^{iθ}起始点变化等价于乘以 e^{iωk}两种操作都只改相位不改幅度所以幅度谱天然免疫。除以基频处理尺度手离镜头远近不同导致轮廓整体放大 k 倍频谱所有系数同比例放大一除就消掉了。num_coeffs20是精度和维数的折中。前 20 个低频系数基本覆盖了手部轮廓的整体形状和主要凸起再往后就是指尖圆角级别的细节对抗噪声没好处。真要调可以对比 12、20、32 三档在验证集上的表现——维数上去之后 SVM 的训练时间会涨但在几百个样本的规模下差别不明显主要还是看泛化。拿到的 20 维向量就是样本库里的「一行」。批量脚本遍历data/raw/下每个类别子目录对每张图跑一遍上面的链路提不出来的掩膜面积不够直接跳过并计数最后统一存成X.npy和y.npy。这里有个细节值得做把跳过的文件名打出来如果某一类跳过的比例明显高于其他类说明那一类的采集条件有问题得回去重采而不是硬着头皮训。4. SVM 训练与实时识别模型怎么训、怎么存、怎么跑特征有了剩下的就是分类器。这一段代码量不大但工程上有几个点不注意模型上线就会失效。4.1 特征矩阵构建与标准化20 维特征里第一维恒为 1归一化后基频除以自己其余各维的量级从前到后递减可能差两三个数量级。SVM 用的是欧氏距离量级大的维度会主导核函数所以标准化必须做而且必须和模型绑在一起from sklearn.pipeline import Pipeline from sklearn.preprocessing import StandardScaler from sklearn.svm import SVC pipe Pipeline([ (scaler, StandardScaler()), # 按列减均值除标准差 (svm, SVC(kernelrbf, probabilityTrue, # 需要输出置信度时打开 class_weightbalanced)) # 类别样本数不均衡时自动加权 ])用Pipeline而不是手动fit_transform再训练关键原因在序列化只要 dump 这一个对象标准化参数和 SVM 参数就一起存下来了推理时不会再出现「忘了标准化」的低级错误。class_weightbalanced按各类样本数的倒数加权采集时拳头比划得多、ok 比划得少这个参数能把少数类拉回来。probabilityTrue会额外做一次内部交叉验证来拟合概率训练慢一些如果你只需要标签关掉它。4.2 网格搜索与模型序列化RBF 核有两个关键超参C 控制对错分的惩罚gamma 控制单个样本的影响半径。用手写猜测纯属浪费时间直接网格搜索import numpy as np, joblib from sklearn.model_selection import GridSearchCV, train_test_split X np.load(data/features/X.npy) y np.load(data/features/y.npy) X_tr, X_te, y_tr, y_te train_test_split( X, y, test_size0.2, stratifyy, random_state42) param_grid { svm__C: [0.1, 1, 10, 100], svm__gamma: [scale, 0.01, 0.05, 0.1], } grid GridSearchCV(pipe, param_grid, cv5, scoringf1_macro, n_jobs-1) grid.fit(X_tr, y_tr) print(best:, grid.best_params_, cv f1:, grid.best_score_) print(holdout acc:, grid.score(X_te, y_te)) joblib.dump(grid.best_estimator_, models/svm.pkl)参数名前面的svm__是 Pipeline 的语法指「流水线里名字叫 svm 的那一步的某个参数」。scoringf1_macro而不是默认的准确率是因为宏平均 F1 对每个类别一视同仁样本少的类别拉胯会立刻反映出来用准确率的话一个占比 60% 的类别就能把分数撑得很好看。stratifyy保证训练集和测试集里各类别比例一致样本量小的时候这个尤其重要否则切分一次可能某个类在测试集里一个都没有。落盘用grid.best_estimator_而不是grid.best_params_因为前者是训练好的完整 Pipeline后者只是参数字典还得自己重建。模型文件通常只有几十 KB跟着代码一起走完全没问题。4.3 摄像头循环里的滑窗投票单帧预测会有抖动尤其是手在动的过程中轮廓不稳定标签会来回跳。工业上的常规做法是加一个长度固定的队列做多数投票import collections, joblib, cv2 import numpy as np from preprocess import clean_and_extract from fourier_descriptor import fourier_descriptor clf joblib.load(models/svm.pkl) buf collections.deque(maxlen7) cap cv2.VideoCapture(0) while True: ok, frame cap.read() if not ok: break contour, mask clean_and_extract(frame) label no-hand if contour is not None: feat fourier_descriptor(contour).reshape(1, -1) pred clf.predict(feat)[0] buf.append(pred) if len(buf) 5: # 攒够 5 帧才开始输出 label collections.Counter(buf).most_common(1)[0][0] cv2.putText(frame, label, (20, 50), cv2.FONT_HERSHEY_SIMPLEX, 1.2, (0, 255, 0), 2) cv2.imshow(gesture, frame) if cv2.waitKey(1) 0xFF 27: # ESC 退出 break cap.release() cv2.destroyAllWindows()deque(maxlen7)固定长度满了自动从左边弹不用手动维护。攒够 5 帧才输出是为了避开刚把手伸进画面的那几帧——那时候轮廓还没稳定预测基本是随机的。投票窗口 7 帧对应到 30fps 的摄像头大概 0.23 秒延迟可以接受如果觉得反应慢缩到 5 帧但抖动会回来一点。clf.predict直接吃(1, 20)的数组因为它内部会自动调用 scaler这也是前面强调整条流水线一起存的原因。如果哪天想换成「置信度低于阈值就不输出」把predict改成predict_proba取max和对应类别即可阈值设在 0.6 附近通常比较稳。5. 常见问题排查五类翻车的现象、成因与解决这套代码跑通不难跑稳不容易。下面五条是实际调试时最常撞上的按现象、原因、解决三段写遇到问题可以直接对号入座。5.1 预处理阶段轮廓抓错、抖动、粘连第一条轮廓跳到脸上。现象是掩膜里同时有手和脸max(cnts, keycv2.contourArea)选了面积更大的人脸输出标签乱跳。原因很直接肤色分割不区分身体部位人脸和手的颜色分布高度重叠面积还常常更大。解决办法是在画面上固定一个 ROI 方框只在框内做分割——让用户把手伸进框里人脸自然被排除。这一步的代价是要多一次数组切片收益是把「背景里有其他肤色区域」这个最难缠的干扰从源头切掉了。如果不想加 ROI退一步的做法是给轮廓加长宽比约束手的轮廓通常比脸扁但这属于打补丁稳定性不如 ROI。第二条握拳时轮廓几乎消失。现象是比划拳头的时候掩膜碎成几块min_area筛不过输出 no-hand。原因是握拳时手部暴露的皮肤面积本来就小加上指缝间的阴影让 Cr 通道的响应掉出阈值区间。解决有两步一是把MORPH_CLOSE的核从 7 加大到 11把指缝间的空洞补上二是把min_area从 3000 降到 1200 左右。但降门槛会带来误检所以更稳的做法是采集阶段就统一手离镜头的距离让所有类别的轮廓面积落在同一个区间里。第三条轮廓边缘像锯齿识别率上不去。现象是存下来的掩膜边缘毛刺很多算出来的高频系数每次都不一样同一姿势连续几帧预测出不同标签。原因通常是光照不均局部过曝导致掩膜在该区域整片丢失。解决顺序是先确认是不是过曝把 mask 和原图并排显示一眼就能看出来是的话在采集端加一盏侧光或者避开顶灯直射软件侧可以在medianBlur之后再做一次MORPH_CLOSE核大小取 5。有个玄学的地方是摄像头自动曝光画面亮度会缓慢漂移建议在VideoCapture之后手动把曝光锁死cap.set(cv2.CAP_PROP_AUTO_EXPOSURE, 0.25)这一步做了之后掩膜稳定性会有肉眼可见的提升。5.2 训练与部署阶段准确率虚高与现场失灵第四条交叉验证 0.99换个人用掉到 0.4。这是最典型的数据泄漏。现象是训练报告漂亮得不像话实际演示时频繁出错。原因几乎总是同一段视频的相邻帧被随机切分到了训练集和测试集——相邻帧的内容差异极小模型相当于在背答案。train_test_split的random_state只能保证切分可复现挡不住泄漏。正确做法是按「录制段」分组划分采集时每录一段就记一个 session id切分时保证同一个 session 的帧只出现在一边或者直接用GroupKFold传groups参数。改完之后分数通常会掉一大截但那才是真实水平。第五条模型换了台机器就失灵。现象是开发机上好好的拷到另一台电脑上所有预测都错。两种可能一种是文件路径写死导致加载了旧模型另一种更隐蔽——训练时用了Pipeline但推理脚本里手动joblib.load了单独的svm.pkl而漏掉scaler.pkl或者反过来把原始特征直接喂给了未标准化的模型。诊断方法是把推理脚本里的特征向量打印出来和训练时保存的X.npy里同类样本比对一下量级对不上就是标准化没走。根因还是序列化策略把整个Pipeline存成一个文件这个坑就填上了。顺带提一个不算 bug 但很坑的点镜像手势。因为取的是幅度谱左手比「OK」和右手比「OK」的频谱完全一样模型分不出来。如果你做的场景需要区分左右手得在特征里额外拼一维带符号的信息比如轮廓质心相对画面中线的偏移或者干脆保留前几个系数的相位。这件事在做需求设计时就得想清楚训完再发现就只能重来。6. 二次开发的几个改造口子换分类器、加类别、做工程化代码跑通之后多数人下一步都会想改点什么。三个方向最实用。换分类器最省事因为特征和分类是解耦的。把SVC换成RandomForestClassifier(n_estimators200)或者KNeighborsClassifier(n_neighbors5)其他代码一行不动直接对比混淆矩阵。样本量在几百的时候KNN 有时反而比 SVM 稳因为它对特征空间的局部结构更敏感样本上千之后 SVM 的优势才明显。换的时候注意 KNN 不需要class_weight但更需要标准化别把 Pipeline 拆了。加新类别的正经流程是四步在data/raw/下建新目录 → 用采集脚本录 100 到 200 帧尽量覆盖不同距离和角度 → 重跑build_dataset.py生成新的X.npy注意要把旧类别一起重算别只算新的→ 重跑train_svm.py。第三步最容易被跳过有人图省事手动往X.npy里 append结果特征顺序和标签顺序对不上训练出来的模型全是乱的。这种血泪经验吃过一次就记住了。工程化方面我一般会把整条链路封成一个类构造函数里加载模型predict(frame)返回标签和置信度内部维护投票队列。这样嵌到 GUI 或者网络服务里都只是多写几行胶水代码。配置全部走config.py别在函数签名里塞默认值。如果目标平台没有 sklearn比如某些嵌入式环境可以考虑把训练好的 SVM 参数导出来自己实现决策函数RBF 核的推理就是几十行 numpy移植成本不高。验证改造效果别只看准确率。我现在的习惯是每次改完都强制走一遍「分组交叉验证 混淆矩阵 录一段没参与训练的现场视频跑一遍」这三步前两步看模型本身第三步看真实环境。从那以后我每次动特征维度或者换分类器都会先把这三步跑完再提交省下来的返工时间远比写验证脚本的时间多。希望帮到你。本文还有配套的精品资源点击获取

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑