尧图网站设计 尧图网站设计YAOTU DESIGN
ARTICLE DETAIL

资讯详情

深耕网站设计与一线实操的经验洞察。

Android实时表情识别Demo搭建:CameraX与NCNN实战指南

Android实时表情识别Demo搭建:CameraX与NCNN实战指南 简介这是一份面向Android开发者的表情识别Demo资源基于深度学习实现面部表情/情绪实时检测适合需要快速集成表情识别能力或研究端侧推理性能的开发者参考。资源共2个文件其中apk为可直接安装到Android手机的演示应用json为构建输出元数据压缩包整体约24.53MB轻量便于下载体验。示例在普通Android手机上即可达到实时识别CPU4线程单次推理约30msGPU约25ms对端侧部署有较好参考价值。截至目前已有1815人学习下载内容聚焦于实际工程落地而非单纯算法讲解。通过安装APK可直观验证识别效果并对比CPU与GPU两种推理模式的实际耗时为移动端表情识别项目的技术选型提供一手数据json文件记录构建信息便于了解APK的打包配置适合用于算法验证和功能预研。资源体积小、开箱即用尤其适合正在做移动端视觉项目或毕业设计的Android工程师。1. 为什么不选现成SDK偏要自己搭一个表情识别Demo前阵子有个朋友找我说想在自己的App里加一个用户情绪感知的功能问有没有现成的表情识别SDK可以接。我给他分析了一圈云端API确实方便但每张图都要上传隐私、延迟、费用全是问题商业SDK的识别效果虽然成熟但可定制性太差换个模型就抓瞎。最后我说不如你自己搭一个Android端实时表情识别Demo模型选型自己定推理框架自己挑跑通了以后想换模型、换场景都轻松。这个Demo的核心链路其实不复杂摄像头实时采集画面把每一帧转成模型能吃的数据格式交给轻量级神经网络推理得到表情分类概率再绘制到预览画面上。整体跑下来可以在不联网的情况下完成实时检测帧率做到25FPS以上完全没问题。适合看这篇内容的人主要是两类一是想在Android端做AI部署但没找到合适起点的开发者二是已经在用现成SDK、但想搞清楚底层原理、减少厂商绑定的同学。如果你对CameraX、NCNN、模型转换这些名词还比较陌生也没关系我下面会把每个环节怎么选、为什么这么选、踩过哪些坑都讲清楚。2. 模型与推理框架选型TFLite、NCNN、ONNX Runtime怎么定2.1 表情识别模型选型轻量优先表情识别本质上是一个图像分类任务。常见的数据集是FER2013包含7类情绪生气、厌恶、恐惧、开心、难过、惊讶、中性。模型输入一般是48x48或者112x112的灰度图/彩色图输出是这7个类别的概率分布。我在这个Demo里没有用特别大的模型而是挑了轻量级的MobileNetV2作为骨干网络把最后的全连接层改成7分类输出。原因很现实Android端的推理要跑在手机CPU/GPU上模型太大、参数量太多帧率就上不去发热和耗电也会变得很难看。MobileNetV2在ImageNet上精度不算顶尖但做表情这种粒度不算细的分类任务已经够了实测下来7类表情的准确率能做到85%上下关键是单次推理在骁龙中端芯片上只需要10~15ms。模型训练可以自己来也可以直接用开源权重。比较省事的路径是下载训练好的ONNX或TFLite模型文件验证完效果再集成。如果你手里已经有PyTorch或Keras的训练脚本转成Android端能跑的格式也不难后面细说。2.2 三种推理框架对比Android端推理框架的主流选择有三个TensorFlow Lite、NCNN、ONNX Runtime Mobile。框架模型格式体积ARM优化上手难度适合场景TensorFlow Lite.tflite中好低快速集成、TF生态NCNN.param/.bin小极好中极致性能、自定义算子ONNX Runtime Mobile.ort中好中多框架转换、Pytorch生态表格里能看出来NCNN在体积和ARM优化的维度上比较突出。它出自腾讯优图对ARM平台做了深度汇编级优化GPU加速支持也很成熟单个so库才几MB非常适合移动端部署。我在这个Demo里就是用的NCNN适配起来不算复杂而且它的模型工具能把ONNX、PyTorch导出的模型统一转成ncnn格式省去很多麻烦。2.3 模型转换与集成方式如果你手头是ONNX模型转NCNN只需要一行命令onnx2ncnn emotion_model.onnx emotion_model.param emotion_model.bin转完后会得到两个文件.param描述网络结构.bin存放权重。Android工程里把这两个文件放进assets目录再把NCNN的libncnn.so和头文件引进来就行。这样模型和代码完全解耦后面换模型只需要替换assets里的文件App逻辑不用动。集成方式上有人直接用Maven依赖有人手动引入so库。我建议手动引入虽然配置麻烦一点但能明确知道哪些ABI架构的so被打进包避免apk体积膨胀。通常只保留armeabi-v7a和arm64-v8a就够了x86架构只在模拟器调试时需要。3. 相机画面怎么喂给模型CameraX图像分析链路3.1 CameraX还是Camera2Android的相机开发方案里Camera2是官方底层API能做的事很多但回调、会话、Surface的配合关系复杂新手照着文档写都能绕晕。CameraX是Jetpack封装的相机库底层还是Camera2但对外提供了更简洁的API而且自带生命周期感知页面销毁时能自动释放相机资源。对于表情识别这种场景我们需要的不是手动控制曝光、对焦这些底层参数而是能稳定拿到每一帧数据就行所以CameraX是更合适的选择。在build.gradle里引入implementation androidx.camera:camera-core:1.2.3 implementation androidx.camera:camera-camera2:1.2.3 implementation androidx.camera:camera-lifecycle:1.2.33.2 用ImageAnalysis拿帧数据CameraX的ImageAnalysis类负责图像分析任务它能在每帧画面到达时回调ImageProxy对象。关键配置有三个val analysisUseCase ImageAnalysis.Builder() .setBackpressureStrategy(ImageAnalysis.STRATEGY_KEEP_ONLY_LATEST) .setTargetResolution(Size(640, 480)) .build() analysisUseCase.setAnalyzer(executor) { imageProxy - // 这里拿到的是 YUV_420_888 格式的帧数据 processImage(imageProxy) }setBackpressureStrategy我习惯用STRATEGY_KEEP_ONLY_LATEST意思是如果上一帧还没处理完新的帧直接丢掉。实时检测场景里保证处理的永远是最新画面比每一帧都处理更有意义。目标分辨率设在640x480不是为了省事而是因为表情识别模型的输入只有112x112左右高分辨率画面转换成本高、推理收益却很低。3.3 YUV转RGB旋转和镜像的坑ImageProxy拿到的默认是YUV_420_888格式但NCNN模型的输入需要RGB数据所以要做一次格式转换。别小看这一步很多新手在这里翻车。YUV_420_888的内存布局是三块planeY平面、U平面、V平面。我用的是最简单也最稳定的转换策略先通过ImageProxy拿到三个plane的字节数组再写一个YUV转RGB的方法按像素坐标从三个平面取值计算。代码长是长了点但性能靠谱fun yuv420ToRgb(image: ImageProxy): Bitmap { val yBuffer image.planes[0].buffer val uBuffer image.planes[1].buffer val vBuffer image.planes[2].buffer // 注意每个plane的rowStride和pixelStride可能不同 // 这里需要按UV分量重采样计算R/G/B }这里最坑的是rowStride和pixelStride。由于内存对齐的原因每一行Y/U/V数据在底层并不是紧凑排列的如果直接用连续数组扫描图片颜色会出现明显的偏绿或条纹。正确做法是按每个平面的stride逐行读取再用U/V的采样偏移还原2x2像素块的颜色。旋转和镜像更是重灾区。后置摄像头预览画面通常需要旋转90度才能保持竖屏方向前置摄像头除了旋转还要做水平镜像否则用户看到自己在屏幕上歪着脸。我的做法是先做YUV到RGB的Bitmap转换再用Canvas统一做旋转和镜像虽然多了一次像素拷贝但逻辑清晰不容易出坐标错乱的问题。4. 推理这一步的细节输入张量、输出概率与UI绑定4.1 构建输入张量拿到Bitmap之后要把它缩放成模型输入尺寸。NCNN的输入是浮点数组排布一般是NCHWN是batch size、C是通道数、H是高、W是宽。表情模型通常是单通道灰度图输入也可以直接用三通道RGB效果差异不大但三通道输入稍微稳定些。预处理这一步很多人会忽略归一化方式与训练时的一致性。如果模型训练时用的是(x / 255.0 - 0.5) / 0.5即标准化到[-1, 1]那么Android端推理也必须做同样的操作否则输入分布不对识别率会肉眼可见地下降。val mat Mat() Utils.bitmapToMat(scaledBitmap, mat) mat.subtract(meanValue, mat) // 通道均值 mat.divide(stdValue, mat) // 通道标准差NCNN的Mat对象可以直接从Bitmap转换过来省去手动遍历像素的操作。4.2 推理与后处理NCNN的推理接口很直接加载模型得到一个Net对象每次推理向它传入输入Mat设置Extractor再取输出。示例代码如下val net Net() net.loadParam(emotion_model.param) net.loadModel(emotion_model.bin) val ex net.createExtractor() ex.input(input, inputMat) val outputMat Mat() ex.extract(output, outputMat) val outputs FloatArray(7) outputMat.toFloatArray(outputs)这7个数字就是7类表情的原始得分一般还要过一层softmax转成概率。由于softmax分母相同代码里可以直接用指数归一化val expScores outputs.map { Math.exp(it.toDouble()) } val sum expScores.sum() val probabilities expScores.map { (it / sum).toFloat() }然后取概率最大的索引映射到表情标签数组[angry, disgust, fear, happy, sad, surprise, neutral]当前帧的表情就出来了。4.3 UI绑定把表情标签画到预览上我推荐用自定义View的方式把结果绘制到预览上层而不是把识别逻辑塞进Activity里。做法是写一个OverlayView重写onDraw()方法用Canvas绘制识别到的表情文字和置信度。override fun onDraw(canvas: Canvas) { super.onDraw(canvas) paint.textSize 48f paint.color Color.WHITE canvas.drawText(表情: $emotionLabel, 40f, 100f, paint) canvas.drawText(置信度: ${%.2f.format(confidence)}, 40f, 160f, paint) }这样UI渲染和应用逻辑解耦之后哪怕要加上人脸关键点框、表情历史曲线都可以在同一个View里扩展。用CameraX的PreviewView做底层画面OverlayView叠在上面两个View的尺寸保持一致就不会出现标注重叠或位置偏移的问题。5. 实时性调优帧率卡顿的排查与实测对比5.1 瓶颈往往不在推理很多人一帧率掉到10FPS就怀疑是模型推理慢其实大多数时候推理只占一小部分时间。我实测过MobileNetV2在骁龙778G上单次推理大概12ms理论能跑到80FPS但加上YUV转RGB、Bitmap缩放、拷贝、UI刷新这些环节整体就掉到了二三十帧。所以优化的第一原则是先profile再动手。在关键方法前后打点记录耗时把每一帧的耗时分布打印出来看看到底卡在哪一步。5.2 线程模型与内存复用图像分析回调本身是跑在Executor线程上的千万别在分析线程里直接更新UI否则会有Choreographer卡顿警告。更稳妥的做法是分析线程只负责计算算完结果用一个原子变量或Handler发到主线程刷新UI。内存复用是我最强调的一个优化点。每次Bitmap.createBitmap都会分配新内存频繁创建会让GC频繁触发表现为帧率周期性掉到个位数。正确做法是在初始化阶段就把Bitmap和字节数组一次性申请好之后每帧都往里面写数据用完不要释放循环复用。private val rgbBuffer ByteArray(width * height * 3) private val rgbBitmap Bitmap.createBitmap(width, height, Bitmap.Config.ARGB_8888)5.3 几个优化项的效果对比我把自己调优过程中记录的几组数据整理了一下供参考优化项帧率提升幅度说明关闭相机回调中所有日志约3~5 FPS高频调用下日志开销很大降低分析分辨率到480p约8~10 FPSYUV转RGB和缩放的耗时显著减少Bitmap复用约15 FPSGC次数大幅降低NCNN开启4线程推理约5 FPS多核并行收益明显分析线程与UI线程解耦帧率稳定消除掉帧和卡顿调的最终结果在骁龙778G上稳定跑到28~32FPS实时性完全够用。在低端机上也做过测试千元机大概18~22FPS虽然没那么丝滑但尚可接受。6. 实战踩坑so库缺失、坐标偏移、识别率飘忽6.1 so库架构不全引发的native闪退这是我第一次集成NCNN时踩的坑在模拟器上跑得好好的一上真机就崩溃Logcat里显示java.lang.UnsatisfiedLinkError: dlopen failed。原因是模拟器是x86架构真机是arm64架构我只打了x86的so真机自然找不到库。后来在app/build.gradle里显式声明ABI架构类型崩溃问题彻底解决android { defaultConfig { ndk { abiFilters armeabi-v7a, arm64-v8a } } }6.2 前置摄像头识别框偏移和镜像问题做实时表情识别时如果你要画出人脸位置还需要从人脸检测模型里拿到人脸框坐标。我一开始把模型输出的坐标直接往OverlayView上画后置摄像头问题不大但切换到前置摄像头后脸的位置左右是反的用户明明在屏幕左侧框却画到了右侧。原因在于前置摄像头预览默认镜像而后置不镜像坐标变换时没有统一坐标系。解决办法是在计算实际绘制坐标前先对x坐标做一次width - x的镜像处理再按旋转角度做矩阵变换。6.3 识别率出乎意料的低极大概率是预处理不一致我一度以为模型效果不行换了几个权重文件都没起色。排查半天发现是模型训练时用了灰度图而我在Android端喂的是彩色图。模型本来就是针对灰度统计的特征彩色输入简直就是另一种域识别率自然惨不忍睹。后来在预处理时把Bitmap转成灰度再把灰度数据喂给模型识别率立刻恢复正常。这类问题非常隐蔽因为程序不会报错结果只是不准确。排查思路就是严格对齐训练时的预处理图像尺寸、通道数、归一化范围、均值方差一个都不能差。6.4 耗电与发热的基础控制实时相机推理本身就是高负载任务完整的调优还需要关注功耗。我的经验是当App退到后台时一定要关闭摄像头和推理线程可以在ON_STOP回调里调用cameraProvider.unbindAll()释放资源如果场景允许可以降低分析帧率比如每秒处理15帧而不是每帧处理减少无效推理。这部分对用户体感影响很大尤其是长时间使用的场景。最后分享一个我自己操作下来最受益的习惯每次改动模型或预处理逻辑之后先跑一遍原本能正确识别的测试图片确认基准效果没退化再上真机调摄像头。这个习惯帮我拦下了好几次调完反而变差了的问题。表情识别只是个起点把这条链路跑通了表情迁移、人脸关键点分析、情绪统计这类功能都可以往这个框架上叠加。本文还有配套的精品资源点击获取
返回列表