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

资讯详情

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

基于CNN与OpenCV的安卓数字手势识别APP设计与部署实战

基于CNN与OpenCV的安卓数字手势识别APP设计与部署实战 简介面向图像识别入门与毕业设计的安卓手势识别项目包基于卷积神经网络实现0-10数字手势的实时识别。包内包含可直接导入Android Studio运行的完整工程以及Python训练脚本、TensorFlow模型文件、OpenCV原生库和大量样本图片覆盖从模型训练到端侧部署的主要环节适合计算机、电子信息等专业学生用于课程设计、期末大作业或毕设参考也便于初学者对照源码理解CNN分类与Android推理流程。整个压缩包144.42MB共2000个文件以jpg样本图1551个、class编译文件163个、java源码142个为核心辅以xml配置、gradle构建脚本、py训练脚本及json等资源目录结构清晰。目前已有88人学习下载可作为数字识别类项目的起步模板和排错参照。1. 数字手势识别 APP 里为什么选 CNN模板匹配的边界与卷积神经网络的取舍在安卓上做数字手势识别第一反应往往是 OpenCV 模板匹配换只手、换个角度、光线一变匹配结果立刻失效。这套基于卷积神经网络CNN的安卓 APP 项目把 0–10 共 11 类数字手势做成一个可直接运行的工程底层依赖 libopencv_dnn.a、libopencv_imgproc.a 等 OpenCV 静态库完成图像预处理和推理模型侧则是典型卷积、池化、全连接结构。对课程设计、期末大作业和毕设来说它足够完整对想搞懂移动端 CNN 部署的人来说它把数据集、训练、导出、JNI 调用全部串了起来值得拆开看一遍。2. 数字手势数据集与预处理从形状归一化到模型输入张量2.1 类别定义与数据量底线先要明确一点0–10 不是 10 类而是 11 类。数字 0 到 9 各是一类数字 10 在多数手势规范里用特殊动作表示需要单独归为一类。数据采集时最忌讳类别数量悬殊比如 0–9 各有 300 张10 只有 50 张训练时 10 类容易欠拟合。我一般建议每类至少准备 200 张折合下来 2200 张起步。这些数据可以用手机摄像头自拍也可以从公开手部数据集里筛选。自拍时要注意背景别太乱尽量固定在一个中性背景前这样后面肤色分割的阈值不用反复调。如果资源里自带训练集这套流程依然适用因为你要验证模型效果就绕不开数据分布是否合理。2.2 用 OpenCV 做肤色分割、尺寸归一化与灰度化CNN 输入不需要彩色信息数字手势的核心是手部形状因此常见做法是把 RGB 图转成 YCrCb 后做肤色阈值分割把背景去掉再取最大轮廓外接矩形缩放到固定尺寸。下面是预处理代码import cv2 import numpy as np def preprocess_hand(path): frame cv2.imread(path) ycrcb cv2.cvtColor(frame, cv2.COLOR_BGR2YCrCb) # 肤色范围在 YCrCb 下比 RGB 更稳定 mask cv2.inRange(ycrcb, (0, 133, 77), (255, 173, 127)) mask cv2.medianBlur(mask, 5) contours, _ cv2.findContours(mask, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE) if not contours: return None c max(contours, keycv2.contourArea) x, y, w, h cv2.boundingRect(c) roi mask[y:y h, x:x w] roi cv2.resize(roi, (64, 64), interpolationcv2.INTER_AREA) roi roi.astype(np.float32) / 255.0 return roi逻辑说明cvtColor 将 BGR 转成 YCrCbinRange 的上下界来自常见肤色区间Cr 133–173、Cb 77–127。medianBlur 用 5×5 核去除皮肤分割产生的椒盐噪声。findContours 在二值图像上找外轮廓RETR_EXTERNAL 只留最外层避免手心纹理产生的内部轮廓干扰。取最大轮廓的矩形框保证手部主体进网络。缩放用 INTER_AREA适合缩小如果输入小于 64×64改成 INTER_CUBIC 效果更好。归一化除以 255把像素压到 [0,1]。需要注意训练和推理的前处理必须完全一致。如果资源里的模型接收的是 128×128 灰度图你按 64×64 导出 ONNX 后安卓端也用 64×64 做 blobFromImage否则推理报错。2.3 数据增强与训练集划分数据增强的意义在于让模型见过更多“不完美的手势”。下表的配置可以直接抄项目配置输入尺寸64×64 单通道训练/验证比8:2随机旋转±15°随机平移±5 像素亮度扰动±20%高斯模糊3×3概率 0.1我用 numpy 实现增强避免引入额外依赖。平移时用 OpenCV 仿射变换标签不变旋转也不影响语义因为 0–10 的手势对角度有一定容忍性但角度过大时 6 和 9 容易混淆这就是增强幅度不建议超过 15° 的原因。def augment(roi): h, w roi.shape angle np.random.uniform(-15, 15) M cv2.getRotationMatrix2D((w // 2, h // 2), angle, 1.0) aug cv2.warpAffine(roi, M, (w, h), flagscv2.INTER_LINEAR) noise np.random.normal(0, 0.02, aug.shape).astype(np.float32) return np.clip(aug noise, 0, 1)warpAffine 的第二个参数是旋转矩阵getRotationMatrix2D 指定中心和角度。加高斯噪声是为了抵抗摄像头传感器噪点但噪声系数 0.02 不能太大否则模型会把噪声特征学进去。划分数据集时用随机打乱保证每个类别都在训练集和验证集中都有分布避免出现某个类别只在验证集里出现的情况。3. 卷积神经网络结构设计与训练细节卷积、池化、步长、核、填充的选择3.1 移动端 CNN 结构为什么不能照搬 VGGVGG 在桌面 GPU 上效果好但安卓端推不动。数字手势只有 11 类特征差异集中表现在指尖张合和轮廓拓扑上用 LeNet-5 级别的轻量结构就足够。网络设计的关键是理解卷积、池化、步长、核、填充这组概念。卷积核kernel_size5表示一次看 5×5 局部区域能捕捉手掌边缘和指尖。步长stride1让每个像素都参与卷积信息不丢失。填充padding2保持特征图尺寸不缩水卷积后宽高不变。池化MaxPool2d(2)把 2×2 区域取最大值步长 2宽高减半挤压出平移不变性。结构设计如下表层核/操作步长填充输出尺寸Conv15×5, 32通道1232×64×64ReLU MaxPool2×22032×32×32Conv25×5, 64通道1264×32×32ReLU MaxPool2×22064×16×16Flatten---16384FC1128 神经元--128Dropout0.5--128FC211 神经元--11输入 64×64 经过两次池化变成 16×1664 倍压缩全连接输入 16384参数量约 200 万在手机 CPU 上单帧推理可以控制在 50ms 左右。如果你用 28×28 输入全连接层变成 64×7×7参数量更小但识别率会掉因为手势细节太少。3.2 PyTorch 训练参数与模型实现模型用 PyTorch 实现训练代码可以直接跑import torch import torch.nn as nn class GestureCNN(nn.Module): def __init__(self, num_classes11): super().__init__() self.features nn.Sequential( nn.Conv2d(1, 32, kernel_size5, stride1, padding2), nn.ReLU(), nn.MaxPool2d(2), nn.Conv2d(32, 64, kernel_size5, stride1, padding2), nn.ReLU(), nn.MaxPool2d(2) ) self.classifier nn.Sequential( nn.Flatten(), nn.Linear(64 * 16 * 16, 128), nn.ReLU(), nn.Dropout(0.5), nn.Linear(128, num_classes) ) def forward(self, x): return self.classifier(self.features(x)) model GestureCNN(num_classes11) criterion nn.CrossEntropyLoss() optimizer torch.optim.Adam(model.parameters(), lr1e-3)CrossEntropyLoss 在 PyTorch 里自带了 Softmax所以模型最后一层不需要再显式加 Softmax直接输出 11 维原始 logits。Adam 的默认参数对这类小数据集很友好不需要像 SGD 那样细致调整 momentum。训练时 batch_size 我用 32epoch 30 左右典型曲线是验证集准确率在前 10 个 epoch 快速爬到 85%之后缓慢提升。如果验证集在 95% 附近震荡不需要再增加 epoch反而要增加数据增强强度。3.3 导出 ONNX给 OpenCV DNN 用的输入输出约定安卓端要用 OpenCV 的 dnn 模块读模型建议导出成 ONNX 而不是 TorchScript因为读 Net 的加载更直接。导出代码如下model.eval() dummy torch.randn(1, 1, 64, 64) torch.onnx.export( model, dummy, gesture.onnx, input_names[input], output_names[output], opset_version11, dynamic_axes{input: {0: batch}, output: {0: batch}} )opset_version 用 11 兼容性最好太高的版本要求手机侧 OpenCV 版本也高。input_names 里的 input 要和后面 C 代码读取时保持一致否则 setInput 时按索引传也行但按名字传更可读。dynamic_axes 把 batch 维度设为动态这样推理时一次只传 1 张图不影响多张测试。导出后可以用 onnxruntime 简单验证一下输出维度避免到安卓端才发现模型文件问题。4. 在安卓 APP 里集成 OpenCV 静态库与模型推理4.1 CMake 配置与 libopencv_*.a 静态库链接资源里可以看到 libopencv_dnn.a、libopencv_imgproc.a、libopencv_core.a、libopencv_ml.a、libopencv_calib3d.a 这些文件说明项目使用的是 OpenCV 的静态库。静态库在链接时会把用到的目标文件直接编进 soAPK 体积变大但不需要在运行时单独加载 OpenCV Manager。在 CMakeLists.txt 里我按下面方式配置add_library(opencv_jni SHARED src/main/cpp/native.cpp) target_link_libraries(opencv_jni ${CMAKE_SOURCE_DIR}/src/main/cpp/libs/armeabi-v7a/libopencv_dnn.a ${CMAKE_SOURCE_DIR}/src/main/cpp/libs/armeabi-v7a/libopencv_imgproc.a ${CMAKE_SOURCE_DIR}/src/main/cpp/libs/armeabi-v7a/libopencv_core.a ${CMAKE_SOURCE_DIR}/src/main/cpp/libs/armeabi-v7a/libopencv_ml.a ${CMAKE_SOURCE_DIR}/src/main/cpp/libs/armeabi-v7a/libopencv_calib3d.a jnigraphics log dl m )链接顺序有讲究OpenCV 各库之间依赖 core 和 imgproc所以把 dnn 放前面calib3d 放后面静态库链接时是单遍扫描的顺序反了会出现 undefined reference。jnigraphics 是 Android 提供的 Bitmap 交互库用它可以直接从 Java Bitmap 拿到像素缓冲区省掉一次 JNI 拷贝。如果项目同时用到多核指令集还要把 libc_shared.so 一起打包。build.gradle 里我记得要给 externalNativeBuild 配置 abiFilters资源里的 .a 文件一般只带一套 ABI常见的是 armeabi-v7a如果你还想要 arm64-v8a得重新编译 OpenCV 或换用动态库版本。4.2 Kotlin 侧 JNI 接口设计APP 界面拿到摄像头预览帧后通过 JNI 把 Bitmap 传到 Native 层。Kotlin 代码非常简单object GestureJNI { init { System.loadLibrary(opencv_jni) } external fun recognizeGesture(bitmap: Bitmap): Int }recognizeGesture 返回 0–10 的数字返回 -1 表示没识别出来。Bitmap 格式必须是 ARGB_8888否则 Native 层读像素的时候通道顺序会乱。CameraX 的 ImageAnalysis 默认给的是 YUV_420_888需要先转成 Bitmap 再调用。4.3 Native 层 Mat 转换与 dnn 推理C 侧核心实现如下#include opencv2/core.hpp #include opencv2/imgproc.hpp #include opencv2/dnn.hpp using namespace cv; static dnn::Net net; void init_net() { net dnn::readNetFromONNX(/data/data/你的包名/files/gesture.onnx); net.setPreferableBackend(dnn::DNN_BACKEND_OPENCV); net.setPreferableTarget(dnn::DNN_TARGET_CPU); } int recognizeGesture(JNIEnv *env, jobject bitmap) { Mat rgba; // 通过 AndroidBitmap_lockPixels 把 Bitmap 转成 Mat // 省略 JNI 代码rgba 是 ARGB 四通道 Mat gray, resized, blob; cvtColor(rgba, gray, COLOR_RGBA2GRAY); resize(gray, resized, Size(64, 64), 0, 0, INTER_AREA); blobFromImage(resized, blob, 1.0 / 255.0, Size(64, 64), Scalar(), true, false); net.setInput(blob, input); Mat prob net.forward(output); double minVal 0, maxVal 0; Point minLoc, maxLoc; minMaxLoc(prob, minVal, maxVal, minLoc, maxLoc); return maxVal 0.6 ? maxLoc.x : -1; }blobFromImage 负责把单通道 64×64 灰度图变成 NCHW 格式的浮点张量。这里 scale1.0/255.0size64×64mean 不设是因为训练前已经归一化过。swapRB 置 true 在灰度图上不影响结果但如果你训练时用了三通道输入必须确认通道顺序是 RGB 还是 BGROpenCV 默认 BGRPyTorch 训练时常用 RGB这一点经常导致识别率莫名其妙掉十几个点。minMaxLoc 返回的 maxLoc.x 就是概率最大的类别索引也就是 0–10 的预测值。概率低于 0.6 时返回 -1UI 层可以显示“未识别”。OpenCV dnn 后端在 CPU 上跑这个小模型很快不用配置 OpenCL因为手机 GPU 驱动差异大OpenCL 反而容易踩坑。5. 识别实战调优阈值、光照与错误手势的误判排查5.1 置信度阈值不是固定的0.6 的阈值是经验值放到不同场景要调整使用场景建议阈值原因固定手机支架手掌正对镜头0.5背景稳定输入质量高手持手机随手比划0.7抖动多需要过滤低置信度课堂/答辩演示0.8避免频繁误报展示更可靠识别 10 的特殊手势0.710 的手势容易与其他手势共享局部特征阈值调高会降低误报但也会把真正的数字误判成 -1。我一般先跑一遍验证集统计每个类别的平均置信度取所有类别里最低的平均置信度作为阈值下限。如果验证集上 7 的平均置信度只有 0.55阈值设 0.6 就会让 7 永远识别不出这个时候要回去补 7 的数据而不是调低全局阈值。5.2 光照不足时先做 CLAHE 再输入模型逆光或灯光发黄时肤色分割后的二值图像边缘会断裂。可以在灰度图送入 blob 前先做一次限制对比度自适应直方图均衡化PtrCLAHE clahe createCLAHE(2.0, Size(8, 8)); clahe-apply(gray, gray);createCLAHE 的两个参数clipLimit2.0 表示限制对比度幅度太小起不到增强作用太大噪声也被放大tileGridSize8×8 把图像切分成小区域分别做直方图均衡。这个操作能明显改善手部轮廓边界但一定要在训练增强阶段加入同样的操作不然推理分布和训练分布不一致。另一个小技巧是训练时对图像做随机的 gamma 矫正模拟不同屏幕亮度和环境光下的输入。5.3 特定数字互相混淆时的排查路径最常见的是 1 和 7、2 和 3 这类指尖状态接近的类别。先用一段录制的视频跑离线识别把每一帧的输出概率打印出来观察 maxVal 和次大类别的概率差值。如果差值小于 0.1说明两个类别在特征空间靠得太近问题大概率在数据而不是模型结构。解决办法有三个方向增加该类别的样本量、增强时加入更大幅度的平移、把网络倒数第二层维度从 128 提到 256。前两个属于数据层面先说数据。平移增强时如果手掌位置偶尔超出边界裁剪就会截断指尖模型会把“残缺的指尖”当成一个特征所以增强代码里要用 borderModecv2.BORDER_REPLICATE 补边。模型层面增大 FC1 容量是最后手段毕竟安卓端对体积敏感。6. 把识别结果做成流畅 UI预览抽帧、结果去抖与反馈提示6.1 每帧都推理太浪费200ms 抽一帧即可CameraX 预览回调频率可能到每秒 30 帧CNN 单帧推理即使只要 50ms手机也会发热掉帧。常见做法是限定最小推理间隔例如 200ms也就是每秒最多 5 次识别足够 UI 界面上显示手势数字了。Kotlin 里在 ImageAnalysis 的分析函数开头加一个时间判断if (SystemClock.elapsedRealtime() - lastInferTime 200) return lastInferTime SystemClock.elapsedRealtime()这里注意 return 之前要调用 image.close()CameraX 的接口要求每帧必须释放否则画面会逐步卡住。6.2 用滑动窗口去抖避免数字来回跳单帧识别不稳定时界面上的数字会在 1 和 7 之间跳。维护一个最近 5 次结果的队列要求同一结果出现至少 3 次才更新 UI这是一种最简单有效的时序滤波private val recentResult ArrayDequeInt() private fun filterResult(result: Int): Int? { recentResult.addLast(result) if (recentResult.size 5) recentResult.removeFirst() val freq recentResult.groupingBy { it }.eachCount() val best freq.maxByOrNull { it.value } ?: return null return if (best.value 3) best.key else null }每次识别结果到来先 push 进队列只有从队列里统计出的众数并且出现次数达到 3才把数字刷新到 TextView。这样即使某一帧误判成其他数字只要后续帧稳定UI 不会跟着跳动。还有一个小技巧把识别结果和置信度一起返回置信度小于阈值的 -1 结果不入队这样空手势不会污染队列。6.3 识别反馈的手势匹配提示对课程设计演示来说比数字更重要的是让参观者看懂你在比划什么。可以在识别成功后显示一个对应的手势示意图同时把置信度百分比显示在角落。0–10 每张示意图提前放本文还有配套的精品资源点击获取
返回列表