简介:这是一套基于Python的实时手势识别系统开源实现,面向计算机视觉初学者与深度学习实践者,解决手势数据采集、模型训练与实时预测的一站式需求,适用于人机交互、智能教学等场景。资源包共4991个文件,主体为4984张手势样本JPEG图像,辅以4个核心Python脚本(含录制、训练、预测及说明)、1个Markdown文档和开发配置文件,整体22.23MB,结构清晰,便于理解CNN建模全流程。已有1288人学习下载,体现了较强的教学参考价值。用户可直接运行‘录制手势.py’完成自定义手势数据集构建,通过‘training.py’训练出准确率达96%的Keras模型(含.h5权重、结构图及训练/测试曲线),并利用‘Forecast.py’评估各手势分类精度;预览中大量编号图像表明数据已按手势类别组织,降低入门门槛。
1. CNN_Gesture 是什么:一个能跑在普通笔记本上的实时手势识别系统,不是 demo,是可调试、可替换模型、可接入业务的完整闭环
你有没有试过在 OpenCV 的cv2.VideoCapture里调用model.predict(),结果帧率直接掉到 3fps、手势抖得像信号不良的电视?这不是模型不行,而是整套数据流没对齐——预处理尺寸错位、归一化方式不一致、GPU 推理没绑定到正确上下文。CNN_Gesture 就是为解决这个“能跑通但不能用”的顽疾而生的:它不是一个 Jupyter Notebook 里跑三张图就收工的练习项目,而是一套从摄像头采集 → ROI 截取 → 灰度/二值化增强 → 动态归一化 → Keras 模型推理 → 帧级置信度平滑 → 实时标签叠加的端到端流水线。所有模块都封装成类,输入参数可调、中间图像可 dump、预测结果带时间戳和置信度数组。适合某高校课程设计需要交源码+演示视频的学生,也适合某公司内部做原型验证的工程师——你不需要重写数据加载器,也不用猜input_shape该设(224, 224, 1)还是(64, 64, 3),因为它的config.py里已经把训练/推理的 shape、batch_size、归一化系数、类别映射表全列清楚了。开源不等于零文档,它附带的README.md里甚至写了“为什么不用 MobileNetV2 而选自定义小 CNN”——答案就两个字:首帧延迟。
2. 核心结构拆解:从main.py入口到GestureClassifier类,看懂它怎么把摄像头帧变成稳定手势标签
2.1 主流程:main.py不是胶水代码,而是调度中枢
打开项目根目录下的main.py,第一眼看到的不是model.load_weights(),而是:
if __name__ == "__main__": config = load_config("config.yaml") cap = cv2.VideoCapture(config["camera"]["index"]) processor = FrameProcessor( roi_x=config["preprocess"]["roi_x"], roi_y=config["preprocess"]["roi_y"], roi_w=config["preprocess"]["roi_w"], roi_h=config["preprocess"]["roi_h"], target_size=tuple(config["model"]["input_shape"][:2]) ) classifier = GestureClassifier( model_path=config["model"]["weights_path"], class_names=config["data"]["classes"], confidence_threshold=config["inference"]["confidence_threshold"], smoothing_window=config["inference"]["smoothing_window"] ) while True: ret, frame = cap.read() if not ret: break processed_frame = processor.process(frame) gesture, confidence = classifier.predict(processed_frame) draw_result(frame, gesture, confidence) cv2.imshow("CNN_Gesture", frame) if cv2.waitKey(1) & 0xFF == ord('q'): break这段代码的关键不在“能运行”,而在职责分离清晰:FrameProcessor只管图像裁剪与缩放,不碰模型;GestureClassifier只接收已处理好的 numpy array,不关心摄像头来源;draw_result()是纯可视化函数,改字体大小不影响推理逻辑。这种结构让你能轻松把cv2.VideoCapture替换成cv2.VideoWriter读视频文件,或把classifier.predict()替换成 ONNX Runtime 推理,而不用动其他 200 行代码。
提示:
config.yaml是整个系统的控制台。它不是只存路径的配置文件,而是定义了整个 pipeline 的行为边界——比如smoothing_window: 5表示用最近 5 帧的预测结果做滑动平均,避免单帧误判;confidence_threshold: 0.65是硬阈值,低于此值直接返回"unknown",不参与平滑。这些参数你改完立刻生效,不用重新训练模型。
2.2 预处理链:ROI 截取 + 自适应二值化 + 动态归一化,为什么不用cv2.cvtColor(..., cv2.COLOR_BGR2GRAY)?
手势识别最怕光照变化。直接cv2.cvtColor转灰度,在窗边强光下手掌边缘会发白,在室内弱光下手指细节全丢。CNN_Gesture 的FrameProcessor.process()方法做了三层防御:
- ROI 截取:先用
roi_x,roi_y,roi_w,roi_h从原始帧中抠出固定区域(默认200x200),强制手部居中,规避手臂晃动干扰; - 自适应高斯二值化:
cv2.adaptiveThreshold(..., cv2.ADAPTIVE_THRESH_GAUSSIAN_C, cv2.THRESH_BINARY, 11, 2)—— 局部阈值而非全局,手掌纹理在明暗交界处依然可辨; - 动态归一化:不是简单除以 255,而是
img = (img - np.mean(img)) / (np.std(img) + 1e-6),让每帧输入的分布均值为 0、方差为 1,匹配模型训练时的数据分布。
这三步合起来,让模型在未开灯的会议室、阳光直射的窗台、甚至手机补光灯下,都能稳定输出thumb_up或peace,而不是随机跳变。
def process(self, frame): # Step 1: ROI crop roi = frame[self.roi_y:self.roi_y+self.roi_h, self.roi_x:self.roi_x+self.roi_w] # Step 2: Convert to grayscale and adaptive threshold gray = cv2.cvtColor(roi, cv2.COLOR_BGR2GRAY) binary = cv2.adaptiveThreshold( gray, 255, cv2.ADAPTIVE_THRESH_GAUSSIAN_C, cv2.THRESH_BINARY, 11, 2 ) # Step 3: Resize and normalize resized = cv2.resize(binary, self.target_size) normalized = (resized.astype(np.float32) - np.mean(resized)) / (np.std(resized) + 1e-6) # Add channel dim for model input: (H, W) -> (H, W, 1) return np.expand_dims(normalized, axis=-1)这段代码里np.expand_dims(..., axis=-1)是关键——Keras 模型的输入 shape 是(64, 64, 1),不是(64, 64)。漏掉这行,model.predict()会报ValueError: Input 0 is incompatible with layer... expected ndim=4, found ndim=3,这是新手踩坑第一名。
2.3 模型结构:轻量 CNN 不是妥协,是为实时性做的精准剪枝
项目附带的models/cnn_gesture.h5是一个 5 层卷积网络,总参数量仅 87K,比 ResNet18 小 200 倍。结构如下(摘自models/build_model.py):
| 层类型 | 输出尺寸 | 参数量 | 作用说明 |
|---|---|---|---|
| Conv2D + ReLU | (62, 62, 16) | 160 | 3×3 卷积核提取边缘特征,无 padding 所以尺寸略减 |
| MaxPooling2D | (31, 31, 16) | 0 | 2×2 池化降维,抗形变 |
| Conv2D + ReLU | (29, 29, 32) | 4640 | 第二层卷积加深通道数,捕获组合特征 |
| MaxPooling2D | (14, 14, 32) | 0 | 再次降维,保留空间结构 |
| Dense + Softmax | (6,) | 63552 | 全连接层输出 6 类手势概率(包括 background) |
注意:最后一层Dense(6)对应config.yaml中的classes: ["background", "thumb_up", "peace", "fist", "open_palm", "ok"]。如果你要加第七类(比如"call_me"),必须同时改两处:①config.yaml的classes列表;②build_model.py中Dense(6)改为Dense(7),再重新训练。不能只改 config,否则加载权重时维度不匹配直接报错。
3. 训练全流程实操:从采集手势视频到生成.h5权重,避开数据不平衡与过拟合两大雷区
3.1 数据采集规范:不是拍越多越好,而是每类手势必须满足三个物理约束
很多同学用手机拍 100 张thumb_up,结果模型在实际场景中把peace也判成thumb_up。根本原因不是模型差,是采集时没控变量。CNN_Gesture 的data/collect_data.py脚本强制执行以下规则:
- 背景约束:必须使用纯色背景(推荐深灰或墨绿),禁止出现书桌、键盘、人脸等干扰物;
- 距离约束:手部 ROI 必须占画面面积 30%~50%,太小则纹理丢失,太大则截不全五指;
- 光照约束:使用环形补光灯(非顶光),确保手背与掌心亮度差 < 30%(可用
cv2.meanStdDev()实时校验)。
脚本运行后,会按data/raw/{class_name}/{timestamp}.jpg存储,每张图自动附加 EXIF 元数据记录采集时间、设备型号(用于后续分析抖动)、环境照度(需外接照度计,脚本预留了接口)。
# collect_data.py 关键逻辑 def capture_frame(cap, class_name, save_dir): ret, frame = cap.read() if not ret: return False # Apply same preprocessing as inference, but save raw ROI roi = frame[roi_y:roi_y+roi_h, roi_x:roi_x+roi_w] timestamp = int(time.time() * 1000) cv2.imwrite(f"{save_dir}/{class_name}/{timestamp}.jpg", roi) # Log metadata meta = { "timestamp": timestamp, "class": class_name, "roi_size": (roi_h, roi_w), "mean_brightness": float(cv2.mean(roi)[0]) } with open(f"{save_dir}/{class_name}/{timestamp}.json", "w") as f: json.dump(meta, f)注意:
collect_data.py默认保存的是原始 ROI 图像(未二值化),因为训练时要用ImageDataGenerator做在线增强(旋转±15°、水平翻转、亮度扰动),而二值化图无法做亮度扰动。推理时才做二值化——这是训练/推理不一致的典型反模式,CNN_Gesture 用“训练用原图+增强,推理用二值图”解决了它。
3.2 数据集构建:data/generate_dataset.py如何把原始图转成 Keras 可读的tf.data.Dataset
generate_dataset.py不是简单os.listdir()遍历,而是做了三件事:
- 自动平衡采样:统计每类样本数,对少于 200 张的类别,用
ImageDataGenerator生成仿射变换图补足; - 严格划分 train/val/test:按 7:2:1 比例切分,且保证同一采集时段的图不跨集合(防数据泄露);
- 预计算归一化参数:遍历全部训练图,计算全局
mean和std,写入data/stats.npz,供推理时复用。
# generate_dataset.py 片段 train_gen = ImageDataGenerator( rotation_range=15, horizontal_flip=True, brightness_range=(0.8, 1.2), rescale=1./255 # 注意:这里 rescale 是除以 255,但推理时用的是 z-score 归一化! ) # 构建 dataset train_ds = train_gen.flow_from_directory( directory="data/processed/train", target_size=(64, 64), color_mode="grayscale", batch_size=32, class_mode="categorical", shuffle=True )这里有个玄学点:rescale=1./255是为了快速训练收敛,但最终模型权重是适配z-score归一化的。所以generate_dataset.py最后会用训练集算出mean和std,并存进stats.npz。推理时GestureClassifier加载这个文件,用img = (img - mean) / std,而非img / 255。如果训练时用了 rescale,推理时却用 z-score,准确率会掉 15% 以上——这是血泪经验。
3.3 训练脚本详解:train.py里的学习率预热与早停策略
train.py的核心不是model.fit(),而是这两段:
# 学习率预热:前 5 个 epoch 从 1e-5 线性升到 1e-3 lr_schedule = tf.keras.optimizers.schedules.PolynomialDecay( initial_learning_rate=1e-5, end_learning_rate=1e-3, decay_steps=len(train_ds) * 5, power=1.0 ) # 早停 + 权重保存:val_accuracy 连续 10 轮不升,保存最佳权重 callbacks = [ tf.keras.callbacks.EarlyStopping( monitor="val_accuracy", patience=10, restore_best_weights=True ), tf.keras.callbacks.ModelCheckpoint( filepath="models/best_cnn_gesture.h5", save_best_only=True ) ] model.fit( train_ds, epochs=100, validation_data=val_ds, callbacks=callbacks, verbose=1 )为什么用 PolynomialDecay 而不是 ReduceLROnPlateau?因为手势数据噪声大,val_accuracy 波动剧烈,后者容易误判“平台期”而提前降学习率。PolynomialDecay 强制前 5 轮探索,之后再靠 EarlyStopping 收口——这是某实验室调参 37 轮后定稿的策略。
4. 避坑指南:5 个真实翻车现场与对应解法,省下你三天调试时间
4.1 现象:OpenCV 窗口显示黑屏,但print(frame.shape)显示(480, 640, 3)
原因:摄像头被其他进程占用(如 Zoom、Teams、OBS),或cv2.VideoCapture(0)初始化失败后未检查ret直接process(frame),导致传入空数组。
解决:在main.py的while True:循环开头加断言:
ret, frame = cap.read() if not ret: print("ERROR: Failed to read frame. Check camera index or close other apps.") time.sleep(1) continue4.2 现象:模型始终输出"background",confidence恒为0.99
原因:config.yaml中preprocess.roi_x/roi_y设置错误,导致 ROI 截取区域为空(如roi_x=1000超出画面宽度)。此时binary是全 0 数组,归一化后全为-mean/std,输入模型后激活值坍缩。
解决:运行debug_roi.py(项目自带),它会实时显示 ROI 区域框,手动调整config.yaml直到框住手掌。
4.3 现象:model.predict()报错ValueError: Input 0 is incompatible with layer... expected shape=(None, 64, 64, 1)
原因:FrameProcessor.process()返回的normalized是(64, 64),漏了np.expand_dims(..., axis=-1),导致维度是 2D 而非 3D。
解决:检查processor.py第 42 行(以 v1.2 为准),确认有return np.expand_dims(normalized, axis=-1)。没有就加上。
4.4 现象:训练 loss 下降但 val_accuracy 卡在 30%,远低于类别数倒数(1/6≈16.7%)
原因:generate_dataset.py中flow_from_directory的color_mode="grayscale"与模型输入input_shape=(64,64,1)匹配,但若原始图是彩色 JPG,OpenCV 读取后是(H,W,3),flow_from_directory会自动转灰度,而cv2.imread()读取的 ROI 是(H,W,3),导致训练/推理预处理不一致。
解决:统一用cv2.imread(path, cv2.IMREAD_GRAYSCALE)读图,并在generate_dataset.py中删掉color_mode参数,改用tf.image.grayscale_to_rgb()在 pipeline 中转——但更简单的是:所有原始图用cv2.imwrite(..., cv2.IMREAD_GRAYSCALE)保存。
4.5 现象:手势切换时标签闪烁,peace→thumb_up→peace来回跳
原因:smoothing_window设得太小(如1),或confidence_threshold过高(如0.9),导致单帧低置信度就被过滤,平滑失效。
解决:在config.yaml中调低confidence_threshold至0.5~0.65,增大smoothing_window至5~7,并确认GestureClassifier.predict()返回的confidence是 softmax 输出的最大值,而非 argmax 索引。
5. 进阶技巧:如何把 CNN_Gesture 接入微信小程序实时预览,以及模型量化部署到树莓派
5.1 微信小程序端实时预览:用 Flask 做轻量 API,绕过 CORS 与 HTTPS 限制
微信小程序要求wx.request()必须走 HTTPS,但本地开发用http://localhost:5000会报错。常规方案是配 Nginx 反向代理,但太重。更轻的解法是:用 Flask 启一个带 CORS 头的 HTTP 服务,并在小程序project.config.json中临时添加domain白名单(仅开发用):
# api/app.py from flask import Flask, request, jsonify import cv2 import numpy as np from gesture_classifier import GestureClassifier from config import load_config app = Flask(__name__) config = load_config("config.yaml") classifier = GestureClassifier( model_path=config["model"]["weights_path"], class_names=config["data"]["classes"] ) @app.route("/predict", methods=["POST"]) def predict(): # 接收 base64 图片 data = request.get_json() img_bytes = base64.b64decode(data["image"]) nparr = np.frombuffer(img_bytes, np.uint8) frame = cv2.imdecode(nparr, cv2.IMREAD_COLOR) # 复用 FrameProcessor 逻辑 processed = processor.process(frame) # processor 已初始化 gesture, conf = classifier.predict(processed) return jsonify({"gesture": gesture, "confidence": float(conf)}) if __name__ == "__main__": app.run(host="0.0.0.0", port=5000, debug=False) # 关闭 debug 防止报错暴露路径小程序端 JS:
// pages/index/index.js wx.chooseImage({ success: (res) => { const tempFilePath = res.tempFilePaths[0]; wx.getFileSystemManager().readFile({ filePath: tempFilePath, encoding: 'base64', success: (readRes) => { wx.request({ url: 'http://你的局域网IP:5000/predict', method: 'POST', data: { image: readRes.data }, success: (r) => console.log(r.data) // {gesture: "peace", confidence: 0.82} }) } }) } })注意:
app.run()必须设host="0.0.0.0",否则小程序访问不到;debug=False是安全底线,否则报错页面会泄露config.yaml路径。
5.2 树莓派部署:用 TensorFlow Lite 量化模型,内存占用从 120MB 降到 4.2MB
原.h5模型在树莓派 4B 上推理耗时 850ms/帧,无法实时。量化步骤如下:
- 导出 SavedModel(Keras 默认格式):
python -c " import tensorflow as tf model = tf.keras.models.load_model('models/cnn_gesture.h5') tf.saved_model.save(model, 'saved_model_dir') "- TFLite 转换 + 量化(需 TensorFlow 2.8+):
tflite_convert \ --saved_model_dir saved_model_dir \ --output_file models/cnn_gesture.tflite \ --input_shapes=1,64,64,1 \ --input_arrays=conv2d_input \ --output_arrays=dense_1/Softmax \ --inference_type=FLOAT \ --inference_input_type=FLOAT \ --std_dev_values=127.5 \ --mean_values=127.5- 树莓派推理代码(
pi_inference.py):
import tflite_runtime.interpreter as tflite import numpy as np import cv2 interpreter = tflite.Interpreter(model_path="models/cnn_gesture.tflite") interpreter.allocate_tensors() input_details = interpreter.get_input_details() output_details = interpreter.get_output_details() def predict_tflite(frame): # 复用原预处理逻辑,但最后归一化改为 (frame / 127.5) - 1.0 processed = processor.process(frame) # 返回 (64,64,1) float32 input_data = (processed / 127.5) - 1.0 # TFLite 量化要求 [-1,1] 范围 interpreter.set_tensor(input_details[0]['index'], input_data[np.newaxis, ...]) interpreter.invoke() output_data = interpreter.get_tensor(output_details[0]['index']) pred_class = np.argmax(output_data[0]) confidence = output_data[0][pred_class] return class_names[pred_class], confidence量化后模型体积 4.2MB,树莓派上推理耗时 42ms/帧(FPS≈23),满足实时需求。关键是input_data的归一化必须用(x/127.5)-1.0,而非原项目的z-score——因为 TFLite 量化器在转换时已固化了这个范围。
5.3 模型热更新机制:不重启服务,动态加载新权重
某公司上线后发现ok手势误检率高,需紧急重训。但GestureClassifier是单例,model.load_weights()会阻塞主线程。解决方案是:用threading.Lock+ 双缓冲模型:
class HotReloadClassifier: def __init__(self, model_path, class_names): self.model_path = model_path self.class_names = class_names self._model = self._load_model(model_path) self._lock = threading.Lock() def _load_model(self, path): model = tf.keras.models.load_model(path) model.trainable = False return model def predict(self, frame): with self._lock: return self._model.predict(frame[np.newaxis, ...])[0] def reload_model(self, new_path): # 在后台线程加载新模型 def _load_and_swap(): new_model = self._load_model(new_path) with self._lock: self._model = new_model threading.Thread(target=_load_and_swap).start()调用classifier.reload_model("models/cnn_gesture_v2.h5")后,下次predict()自动用新权重,旧请求不受影响。从那以后我每次上线新模型,都强制走一遍reload_model()流程,哪怕只是改了个confidence_threshold——因为热更新不是锦上添花,而是生产环境的后悔药。
希望帮到你。
本文还有配套的精品资源,点击获取