基于傅里叶描述子与SVM的手势识别:频域轮廓特征工程实践

发布时间:2026/11/2 6:47:43
基于傅里叶描述子与SVM的手势识别:频域轮廓特征工程实践

简介:这套基于傅里叶算子的手势识别完整源码面向计算机视觉与机器学习初学者、课程设计及毕业设计开发者,帮助其从图像采集、预处理一路实践到分类模型训练与界面演示。项目在Win10+Python3.7环境完成,包含图像平滑、OTSU肤色分割、八邻域轮廓检测,并提取归一化傅里叶描述子与椭圆傅里叶描述子,使用KNN与SVM训练自采数据集,最后以PyQt5搭建简易交互界面。压缩包共2000个文件,以txt样本与说明、png手势图片、m训练脚本、py程序为主,另含少量文档和模型相关文件,整体约142.52MB,目录按数据、算法与界面模块组织,便于对照复现。已有13881人学习,适合用来理解传统手势识别流程、复现实验并做二次改进。

1. 手势轮廓的频域特征:为什么傅里叶算子比像素更稳

手势识别做到最后,真正卡住人的往往不是分类器,而是轮廓特征稳不稳。这套基于傅里叶算子的手势识别源码,用傅里叶描述子把闭合手势轮廓转成频域系数,再交给 SVM 做机器学习分类,并带了一份样本库。它解决的场景很具体:摄像头下光照变化、手离镜头远近不同、手势有轻微旋转时,直接拿像素或 Hu 矩做特征容易抖,而傅里叶低频系数只保留形状主干,高频噪声被截掉。适合要快速搭 baseline 的课程设计、原型验证,也适合已经会用 OpenCV 找轮廓、想补一套频域特征加 SVM 分类链路的人。资源里预处理、特征提取、训练和单帧预测都有对应脚本,样本库按类别组织,拿到后先跑通再换自己的数据。

2. 从二值图到复数轮廓:傅里叶描述子的计算链路

傅里叶描述子的输入不是整张图,而是一条按顺序排列的闭合轮廓。把轮廓点看成复平面上的序列 (z(n)=x(n)+iy(n)),做 DFT 后,低频系数对应轮廓的大致走向,高频系数对应手指边缘的锯齿和噪声。手势识别要的是“几根手指伸出来、手掌朝向大概怎样”,不是边缘每个像素,所以截断低频比堆像素更划算。选型上,Hu 矩对整体形状敏感但维度低,遇到相似手势区分力不够;直接把重采样后的坐标点喂 SVM 又对旋转和起点太敏感。傅里叶幅值谱正好把平移、尺度、旋转和起点的影响拆开处理。下面按“分割、重采样、FFT、截断归一化”走一遍。

2.1 手势分割与最大轮廓筛选

样本库如果是原始 RGB 图,第一步是把手从背景里抠出来。常见做法是转灰度、高斯模糊、Otsu 二值化、形态学闭运算填补手指间的空洞,再找外轮廓。这里有个容易忽略的点:findContours返回的轮廓顺序不稳定,不能默认第一个就是手。我一般按面积取最大轮廓,同时加一个最小面积阈值,把桌面纹理和噪点过滤掉。阈值不是固定的,和图像分辨率、手在画面里的占比有关;样本库统一采集时可以先统计面积分布,再定一个下限。

import cv2 import numpy as np def largest_contour(mask): contours, _ = cv2.findContours( mask, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_NONE ) if not contours: return None return max(contours, key=cv2.contourArea) def segment_hand(bgr, ksize=5, min_area=3000): gray = cv2.cvtColor(bgr, cv2.COLOR_BGR2GRAY) blur = cv2.GaussianBlur(gray, (ksize, ksize), 0) # Otsu 适合前景背景亮度双峰明显的场景 _, mask = cv2.threshold( blur, 0, 255, cv2.THRESH_BINARY + cv2.THRESH_OTSU ) kernel = np.ones((5, 5), np.uint8) # 闭运算先连断指,开运算再去小噪点 mask = cv2.morphologyEx(mask, cv2.MORPH_CLOSE, kernel, iterations=2) mask = cv2.morphologyEx(mask, cv2.MORPH_OPEN, kernel, iterations=1) cnt = largest_contour(mask) if cnt is None or cv2.contourArea(cnt) < min_area: return None, mask return cnt, mask

ksize必须是奇数,越大越平滑,但手指边缘也会变钝;iterations=2的闭运算能把手指间的小裂缝补上,迭代再多就会把相邻手指粘成一团,后面傅里叶低频系数会直接失真。min_area建议先按“手掌面积占画面比例”估一个值,再拿几十张样本看漏检和误检。如果样本库本身是二值掩码图,不要再走 Otsu,直接对单通道图找轮廓,否则会把掩码再阈值一次,轮廓可能缩水。

2.2 等弧长重采样与复数序列构造

OpenCV 的approxPolyDP会随形状复杂度返回不同点数,今天 37 个点、明天 42 个点,FFT 的长度不固定,特征维度就乱了。傅里叶描述子要求轮廓序列长度固定,所以要先做等弧长重采样:把闭合轮廓按周长均分成 N 个点。这样 N 固定,FFT 输出长度固定,后面截断低频才有意义。重采样前把首尾点接上,避免最后一段弧长被漏掉。

def resample_contour(cnt, n=128): pts = cnt[:, 0, :].astype(np.float32) pts = np.vstack([pts, pts[0]]) # 闭合 d = np.sqrt(np.sum(np.diff(pts, axis=0) ** 2, axis=1)) cum = np.concatenate([[0], np.cumsum(d)]) total = cum[-1] if total < 1e-6: return None s = np.linspace(0, total, n, endpoint=False) x = np.interp(s, cum, pts[:, 0]) y = np.interp(s, cum, pts[:, 1]) return np.stack([x, y], axis=1)

n=128是这套代码里比较稳妥的默认值:太小会丢掉手指轮廓的细节,太大对边缘噪声更敏感,而且 FFT 计算量上来。重采样之后求质心,把每个点减去质心再写成复数:

def contour_to_complex(cnt, n=128): pts = resample_contour(cnt, n=n) if pts is None: return None c = pts.mean(axis=0) z = (pts[:, 0] - c[0]) + 1j * (pts[:, 1] - c[1]) return z

减去质心这一步是平移不变的关键。手在画面里左右移动,轮廓整体平移,但复数序列减掉质心后形状不变。注意质心要用重采样后的点算,不要用原始轮廓点算,否则重采样带来的密度变化会让质心轻微偏移。

2.3 FFT 与描述子截断

对复数序列做 FFT,得到Z[k]。Z[0]是直流分量,代表质心位置,前面已经减掉质心,所以它接近 0,一般不放进特征。取正频率的低频段Z[1]到Z[m],再取幅值。幅值谱对旋转不敏感:轮廓整体旋转相当于复数序列乘一个单位相位因子,FFT 后每个系数都乘同一个相位,取幅值就消掉了。起点变化相当于循环移位,也只影响相位,不影响幅值。尺度归一化用第一个非零低频幅值做分母,让不同大小的手在同一量级上。

def fourier_descriptor(cnt, n=128, m=12): z = contour_to_complex(cnt, n=n) if z is None: return None Z = np.fft.fft(z) low = Z[1:m + 1] # 去掉直流,取低频正频率 mag = np.abs(low) denom = mag[0] if mag[0] > 1e-6 else np.sum(mag) feat = mag / denom return feat.astype(np.float32)

m=12表示特征向量 12 维,只保留前 12 个低频幅值。低频太少,相似手势分不开;低频太多,手指边缘的锯齿和分割噪声会混进来。实际调参时可以把m和 SVM 的C、gamma一起看,但不要一开始就上 40 维,小样本下维度越高越容易过拟合。下面这张表是这条链路里最常改的参数:

参数含义常用起点调大后的影响
n轮廓重采样点数128细节更多,噪声和计算量也上来
m保留的低频描述子个数12区分力增强,过拟合风险增加
ksize高斯核大小5边缘更平滑,手指细节变钝
min_area最小轮廓面积按分辨率估过滤小噪点,但小手势会被漏掉

如果fourier_descriptor返回None,不要静默跳过就完事。训练阶段要统计每类跳过了多少张,超过 5% 就要回头看分割,而不是急着换分类器。特征提取是整条链路的地基,地基歪了,SVM 调参只是在给错误特征做美容。

3. 样本库怎么组织:特征缓存、标签编码与数据划分

样本库不是“有图就行”。标签顺序、特征缓存、训练测试划分方式,直接决定指标是否可信。资源里的样本库按手势类别分目录,代码用sorted遍历目录,保证同一批数据每次跑出来的标签编码一致;如果今天用os.listdir的顺序、明天换了文件系统顺序,标签就会错位,模型保存下来也会把类别名对错。特征提取一次成本不低,尤其 N=128 的 FFT 和轮廓处理,样本一多,每次调参重算会拖慢节奏。我一般把特征和标签缓存成npz,训练只读缓存,样本库变更再重建。划分更不能随机打散所有图片,同一只手在同一段视频里的相邻帧长得几乎一样,随机划分会让测试集混入训练集的近邻,指标虚高。

3.1 目录约定与标签映射

样本库的目录名就是类别名,代码遍历时先收集子目录,再排序,建立class_name -> label_id的映射。这个映射要跟模型一起保存,推理时按同一顺序取类别名。样本图可能是原始 RGB,也可能是已经抠好的二值掩码,读入后要分开处理:三通道走segment_hand,单通道直接当掩码找轮廓。不要对掩码再跑一次 Otsu,否则掩码里的 0/255 会被重新切分,轮廓面积会变。

from pathlib import Path import cv2 import numpy as np def contour_from_image(img): if img is None: return None if img.ndim == 2: mask = img if mask.max() <= 1: mask = (mask * 255).astype(np.uint8) return largest_contour(mask) cnt, _ = segment_hand(img) return cnt def build_dataset(root, n=128, m=12): root = Path(root) classes = sorted([p.name for p in root.iterdir() if p.is_dir()]) label_map = {name: i for i, name in enumerate(classes)} feats, labels, paths = [], [], [] for cls in classes: for img_path in sorted((root / cls).glob("*.png")): img = cv2.imread(str(img_path), cv2.IMREAD_UNCHANGED) cnt = contour_from_image(img) if cnt is None: continue f = fourier_descriptor(cnt, n=n, m=m) if f is None: continue feats.append(f) labels.append(label_map[cls]) paths.append(str(img_path)) return np.vstack(feats), np.array(labels), classes, paths

这里用*.png只是示例,实际样本库是什么后缀就改什么。OpenCV 的imread在带中文的路径下容易读空,常见做法是用np.fromfile读字节再cv2.imdecode,或者把样本目录改成纯英文路径。标签映射用sorted后,类别名建议也用英文或数字,避免路径编码问题。build_dataset返回的classes要跟特征一起缓存,后面混淆矩阵的轴顺序靠它。

3.2 特征缓存与增量更新

调参时最浪费时间的是重复跑轮廓和 FFT。把特征、标签、类别名、样本路径一起存下来,训练脚本直接读缓存,几秒就能跑完一轮网格搜索。缓存不是一劳永逸:改了n、m、分割参数,旧缓存就废了。我一般把关键参数写进缓存文件名,或者存一个meta数组,加载时校验,不匹配就重建。

def save_cache(path, feats, labels, classes, paths, n, m): np.savez_compressed( path, feats=feats, labels=labels, classes=np.array(classes), paths=np.array(paths), n=n, m=m, ) def load_cache(path, expect_n=128, expect_m=12): d = np.load(path, allow_pickle=True) if int(d["n"]) != expect_n or int(d["m"]) != expect_m: raise ValueError("缓存参数与当前特征配置不一致,请重建") return d["feats"], d["labels"], list(d["classes"]), list(d["paths"])

样本库不大时,全量重建比写增量逻辑省心;样本量大到重建要几分钟,再考虑按文件修改时间做增量。增量逻辑最容易翻车的地方是删了样本但缓存没删,训练集里还留着旧特征。缓存文件不要和源码混在一起提交,特征文件体积和样本库规模相关,提交上去只会让仓库变重。

3.3 划分:按人员/会话分组,别按图片随机

手势样本的相邻帧相关性很强。同一个人、同一段视频里连续截 100 帧,随机抽 25% 做测试,测试集里几乎每一张都能在训练集里找到“近邻”,交叉验证分数会好看得离谱,换一段视频就掉下来。正确做法是按人员或采集会话分组:同一个人、同一段视频的样本只能出现在训练集或测试集的一侧。样本库文件名里带人员编号或会话编号时,直接从前缀解析groups;没有的话,至少按“每类前 70% 视频训练、后 30% 测试”切,不要打散帧。

from pathlib import Path import numpy as np from sklearn.model_selection import GroupShuffleSplit # 假设文件名形如 s01_gesture_001.png,s01 是人员/会话编号 groups = np.array([Path(p).stem.split("_")[0] for p in paths]) gss = GroupShuffleSplit(n_splits=1, test_size=0.25, random_state=42) train_idx, test_idx = next( gss.split(feats, labels, groups=groups) ) X_train, X_test = feats[train_idx], feats[test_idx] y_train, y_test = labels[train_idx], labels[test_idx]

GroupShuffleSplit不保证每类比例完全一致,小样本时训练前要打印np.bincount(y_train)和np.bincount(y_test),看有没有某一类在测试集里只剩一两个样本。如果样本库连人员编号都没有,至少把“训练/测试”写进采集流程,别让同一段视频的帧跨集合。指标报告里要写清楚划分方式,不然 0.98 的准确率没有复现意义。

4. SVM 训练与调参:RBF 核、C 与 gamma 的落地点

特征维度十几到几十维、样本量几百到几千,SVM 是这套链路里很合适的选择。RBF 核能处理非线性边界,小样本下比深度模型更容易调,训练完模型文件也小。但 SVM 对特征尺度敏感:傅里叶幅值归一化后第一维是 1,后面递减,不同维度方差差异大,不标准化的话 RBF 核的距离会被大幅值维度主导。C越大越不容忍错分,gamma越大每个样本的影响范围越小,两者一起把决策边界从“平滑”推到“绕着样本转”。用Pipeline把标准化和 SVM 绑在一起,交叉验证时标准化参数只在训练折上拟合,避免测试折统计量泄漏。评分用f1_macro而不是准确率,样本不均衡时准确率会被多数类带偏。

4.1 Pipeline 与特征标准化

StandardScaler对每个特征维度减均值、除标准差。傅里叶幅值谱的数值范围跨几个数量级,标准化后 RBF 核的欧氏距离才有意义。class_weight="balanced"按类别频率反比加权,样本库每类数量不一致时能缓解模型偏向多数类。probability=True会做 Platt 校准,推理时能拿置信度做阈值过滤,但训练时间会增加;只取类别标签时可以关掉。

from sklearn.pipeline import Pipeline from sklearn.preprocessing import StandardScaler from sklearn.svm import SVC pipe = Pipeline([ ("scaler", StandardScaler()), ("svm", SVC( kernel="rbf", class_weight="balanced", probability=True, random_state=42, )), ])

不要在外面先fit_transform整个特征集再交叉验证,那样测试折的均值和方差已经进了训练。Pipeline 的价值就在这儿:网格搜索每一折都重新拟合 scaler。特征缓存里存原始傅里叶幅值,不要存标准化后的值,标准化必须在训练折上做。

4.2 网格搜索与交叉验证

C和gamma的搜索范围先粗后细。小样本常用gamma="scale",它等于1/(n_features * X.var()),比固定值稳。C从 0.1 到 100 先扫一遍,看mean_test_score和std,再在最优附近缩小范围。StratifiedKFold保证每折类别比例接近,shuffle=True打乱样本但不会跨组,因为前面已经按组切了训练/测试。如果要做更严格的组内交叉验证,把groups传给GroupKFold,别用普通 K 折。

from sklearn.model_selection import GridSearchCV, StratifiedKFold param_grid = { "svm__C": [0.1, 1, 10, 100], "svm__gamma": ["scale", 0.01, 0.1, 1.0], } cv = StratifiedKFold(n_splits=5, shuffle=True, random_state=42) grid = GridSearchCV( pipe, param_grid, cv=cv, scoring="f1_macro", n_jobs=-1, verbose=1, ) grid.fit(X_train, y_train) print("best:", grid.best_params_, grid.best_score_)

n_jobs=-1会吃满 CPU,样本量大时内存也上来;如果机器吃不消,改成 2 或 4。verbose=1能看到每折进度,调参时别省这个输出。best_score_是交叉验证均值,不是测试集分数;测试集只在最后看一次,反复拿测试集调参等于把测试集变成验证集。

4.3 模型保存与推理一致性

训练完不能只存一个SVC对象,还要存类别名、n、m、分割参数。推理时如果训练用 128 点重采样、推理用 64 点,特征维度直接对不上;如果训练用掩码图、推理用 RGB 图,分割分布不一致,置信度会整体偏移。我一般存一个字典,把模型和特征配置绑在一起。

import joblib bundle = { "model": grid.best_estimator_, "classes": classes, "n": 128, "m": 12, } joblib.dump(bundle, "hand_svm.joblib") def predict_one(bgr, bundle): cnt, _ = segment_hand(bgr) if cnt is None: return None, 0.0 f = fourier_descriptor(cnt, n=bundle["n"], m=bundle["m"]) if f is None: return None, 0.0 proba = bundle["model"].predict_proba([f])[0] idx = int(np.argmax(proba)) return bundle["classes"][idx], float(proba[idx])

predict_proba的置信度不是绝对概率,只适合做阈值过滤和滑动窗口投票,不要拿它当“模型有多确定”的严格解释。推理阶段如果连续多帧返回None,先看segment_hand的min_area是不是把远距离的手滤掉了,再看光照是不是让 Otsu 失效。模型文件要和特征缓存版本一起管理,换m不换模型,报错会比预测错更好排查。

5. 避坑排查:轮廓、归一化与样本的五个翻车点

这套链路跑通不难,难的是指标忽高忽低。傅里叶描述子本身不复杂,翻车大多发生在轮廓、归一化和样本划分上。下面五条是血泪经验里出现频率最高的,每条按“现象、原因、解决”拆开。遇到问题时先别怀疑 SVM 的核函数,把中间轮廓画出来看一眼,很多问题当场就现形。

5.1 最大轮廓选到手臂或背景

现象:预测结果在某一类反复横跳,可视化时绿色轮廓跑到袖子、桌面边缘或者画面角落,手指轮廓只覆盖一小段。原因:Otsu 把和手亮度接近的背景一起当前景,最大面积轮廓不是手;或者手臂伸进画面,手臂面积比手掌大。解决:加 ROI 限制,只保留画面中心区域;按面积、长宽比、实心度过滤轮廓;有肤色条件时加一层肤色掩码;深度摄像头场景直接用深度阈值。最直接的办法是把轮廓叠回原图,看一眼再调参。

def debug_contour(bgr, cnt): vis = bgr.copy() cv2.drawContours(vis, [cnt], -1, (0, 255, 0), 2) cv2.imshow("contour", vis) cv2.waitKey(0)

轮廓可视化要成为固定动作。训练集里抽每类 10 张跑一遍,看最大轮廓是不是手;如果有一类总是选到背景,先修分割,不要急着加特征。

5.2 第一个非零系数接近 0 导致数值爆炸

现象:特征里出现1e6这种量级,SVM 训练不收敛或者所有样本都预测成同一类。原因:轮廓接近圆形、质心偏移或者重采样点分布不均时,Z[1]的幅值很小,用它做分母会把噪声放大。解决:分母加eps,或者改用总幅值和、轮廓周长归一化;对特征做log1p压制长尾。归一化后打印每维的最大最小值,出现异常值就回到轮廓看。

mag = np.abs(Z[1:m + 1]) denom = mag[0] if mag[0] > 1e-3 else (np.sum(mag) + 1e-6) feat = np.log1p(mag / denom)

log1p不是必须的,但小样本下能让特征分布更稳。注意训练和推理要用同一套归一化逻辑,不能训练用log1p、推理忘了。

5.3 旋转增强把语义转没了

现象:训练时加了 90 度、180 度旋转增强,交叉验证分数上去了,实际测试时手指朝上的手势被认成朝下。原因:傅里叶幅值谱对旋转不敏感,但手势语义不是旋转不变的,“手掌朝上”和“手掌朝下”在业务上可能是两个类别。解决:只做小角度增强,比如正负 15 度,模拟采集时的自然倾斜;如果方向本身是分类依据,额外加方向特征,比如质心到最远点的角度、包围盒长宽比、手指尖相对质心的象限分布。

幅值谱的旋转不变性是一把双刃剑。它帮你抵消采集时的轻微旋转,也会把有语义的方向信息一起抹掉。样本库类别定义里如果包含方向,别盲目上全角度旋转增强。

5.4 训练集和测试集同源,指标虚高

现象:交叉验证 0.98,换一个人、换一段视频掉到 0.6,混淆矩阵里某一类几乎全错。原因:随机划分把同一段视频的相邻帧分到了训练和测试,模型记住了背景和手部纹理,不是形状。解决:按人员或会话分组划分,测试集留整段视频;报告里写清楚分组方式;调参只看训练折,测试集最后看一次。样本库没有人员编号时,至少按采集顺序切,别打散帧。

这个问题最像玄学,其实是数据泄漏。指标高得离谱时,先查划分,再查特征里有没有混进背景颜色、画面位置这种“捷径特征”。

5.5 描述子维度不一致

现象:推理时报X has n features, but SVC is expecting m features,或者缓存加载后训练集和测试集维度对不上。原因:训练用m=12,推理代码默认m=20;重采样失败返回None被跳过,导致某个类别样本数变化;缓存没带参数,换了n还在读旧特征。解决:把n、m和分割参数存进模型 bundle;缓存文件带meta,加载时校验;启动时打印一次特征维度,和模型期望值比对。

维度不一致是最好修的一类问题,前提是别静默跳过。build_dataset里跳过样本要计数,按类别打印跳过率,超过阈值就停下来看原因。

6. 实时链路验证:滑动窗口投票与跨人测试

离线混淆矩阵好看,不代表摄像头前能用。单帧预测会抖,因为轮廓在连续帧里跳变,SVM 在边界样本上输出会在两类之间切换。我一般加一个长度 7 的滑动窗口,只接收置信度大于 0.6 的结果,窗口内多数投票作为最终输出。这样牺牲几帧延迟,换稳定显示。窗口太短压不住抖动,太长手势切换会拖尾;7 帧在 30fps 下大约 0.23 秒,是一个能接受的手感。

from collections import deque, Counter def realtime(bundle, cap_index=0, window=7, conf_th=0.6): cap = cv2.VideoCapture(cap_index) buf = deque(maxlen=window) while True: ok, frame = cap.read() if not ok: break label, conf = predict_one(frame, bundle) if label is not None and conf > conf_th: buf.append(label) if len(buf) == window: final = Counter(buf).most_common(1)[0][0] cv2.putText(frame, final, (10, 40), cv2.FONT_HERSHEY_SIMPLEX, 1.2, (0, 255, 0), 2) cv2.imshow("hand", frame) if cv2.waitKey(1) & 0xFF == 27: break cap.release() cv2.destroyAllWindows()

验证不要只看总准确率,按下面几个维度各测一轮,记录每类召回和置信度分布:

验证维度做法观察指标
光照室内、侧光、逆光各采一段每类召回是否掉到 0.7 以下
距离0.5m、1m、1.5m 各做一组轮廓面积和描述子是否稳定
旋转同一手势转 0、30、60 度幅值谱是否稳、语义是否被混淆
人员留一个人完全不参与训练跨人测试的宏平均 F1

从那以后我每次接手势项目,都先把轮廓可视化跑一遍,再谈模型指标。希望帮到你。

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