简介:这是一份面向Android开发者的表情识别Demo资源,基于深度学习实现面部表情/情绪实时检测,适合需要快速集成表情识别能力或研究端侧推理性能的开发者参考。资源共2个文件,其中apk为可直接安装到Android手机的演示应用,json为构建输出元数据,压缩包整体约24.53MB,轻量便于下载体验。示例在普通Android手机上即可达到实时识别:CPU(4线程)单次推理约30ms,GPU约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还是Camera2
Android的相机开发方案里,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.3"3.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的内存布局是三块plane:Y平面、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的输入是浮点数组,排布一般是NCHW:N是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 FPS | YUV转RGB和缩放的耗时显著减少 |
| Bitmap复用 | 约15 FPS | GC次数大幅降低 |
| 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帧而不是每帧处理,减少无效推理。这部分对用户体感影响很大,尤其是长时间使用的场景。
最后分享一个我自己操作下来最受益的习惯:每次改动模型或预处理逻辑之后,先跑一遍原本能正确识别的测试图片,确认基准效果没退化,再上真机调摄像头。这个习惯帮我拦下了好几次"调完反而变差了"的问题。表情识别只是个起点,把这条链路跑通了,表情迁移、人脸关键点分析、情绪统计这类功能,都可以往这个框架上叠加。
本文还有配套的精品资源,点击获取