Android边缘AI实战:TFLite猫脸识别门禁与MQTT串口双通道
2026/9/19 14:05:50 网站建设 项目流程

给自家猫主子做一扇“认脸才开门”的猫门,这个念头我在脑子里转了很久。最开始想的是买现成的宠物门,结果发现市面上大多数是靠 RFID 项圈或者红外感应——项圈猫不爱戴,红外是只猫走过就开,邻居家的橘猫照样能进来蹭饭。后来琢磨着把摄像头、识别模型和电控锁串起来,做成一套只认自家猫的门禁系统。这次我选的路子是用 Android 主板当边缘计算盒子,本地跑猫脸识别模型,识别通过再驱动门锁,整条链路不依赖云端,断网也能用。整套东西涉及 Android 相机采集、TFLite 推理、人脸识别那套流水线改造、MQTT 与串口通信、以及 7x24 运行的稳定性打磨,工作量不算小,但每一步都能拆开单独调试。这篇文章会把我在这个项目里踩过的坑、参数怎么定的、代码怎么写的一次性讲清楚,适合有 Android 基础、想入门边缘 AI 落地的开发者,也适合想给自己家宠物做点小玩意的硬件爱好者。哪怕你只是想搞明白“猫脸识别到底和人脸识别差在哪”,看完也能有个清晰的判断。

1. 方案定调:为什么把猫脸识别放到 Android 板子上跑

1.1 三条技术路线的横向对比

做宠物识别门禁,摆在面前的路其实就三条:纯 MCU + 云端 API、自建云服务识别、以及 Android 本地边缘推理。我三条都试过或者评估过,最后选 Android 边缘推理,不是因为它最便宜,而是它在“离线可用”和“开发效率”这两点上综合得分最高。

方案算力来源单机物料成本断网可用识别延迟开发周期隐私性
MCU + 云端 API云端服务器低(80~150 元)500ms~2s1~2 周差,图像要上传
自建云服务识别自建服务器中(200~400 元)300ms~1s3~6 周中,仍要传图
Android 本地推理板载 GPU/NPU中高(400~900 元)80~300ms2~4 周好,图像不出设备

纯 MCU 那条路我一开始很心动,一块 ESP32-CAM 才几十块,但问题马上就来了:图像要传到云端做识别,家里的上行带宽和 Wi-Fi 稳定性直接把体验拖垮,猫在门口站两秒没反应就走了,而且每次识别失败都是一次“猫被关在门外”的糟糕体验。更麻烦的是延迟不可控,路由一抖动就是好几秒。Android 板子虽然贵,但它自带 Linux 内核、成熟的相机框架、GPU 和现成的推理运行时,等于把最难的那部分基础设施白送给你了。

还有一点容易被忽略:Android 设备天然支持屏幕、扬声器、Wi-Fi、蓝牙、USB 外设,你后期想加个“识别成功后播一句主人录的语音”或者“本地屏幕显示今天进出了几次”,几乎不用改架构。MCU 要做这些就得再加一堆外设,代码量反而上去了。

1.2 猫脸和人脸到底差在哪,别直接套现成模型

这是整个项目里最关键的认知转变。我一开始想得很简单:人脸识别那套 MTCNN + ArcFace 不是现成的吗,直接拿猫脸图喂进去不就行了?实测下来准确率惨不忍睹,原因有这么几条。

第一是几何比例完全不同。人脸是竖长的椭圆形,五官分布符合严格的三庭五眼;猫脸是宽扁的倒三角,眼睛几乎在同一水平线上且间距很大,鼻子在正下方,耳朵长在头顶两侧。用为人脸设计的 anchor 比例去做检测,猫脸框要么框不全,要么把整只猫头都框进去。

第二是姿态自由度大得多。人会配合镜头,猫不会,它可能侧躺、仰头、只露半张脸、被自己的爪子挡着。而且猫的瞳孔在暗光下会放大到几乎占满眼球,强光下缩成一条缝——同一个特征点在两种光照下长得完全不一样。

第三是毛发纹理干扰。人脸的关键点靠皮肤边界和五官轮廓就能定,猫脸的关键点周围全是毛发,边缘模糊,标注一致性很差,这也是为什么公开的猫脸关键点数据集质量普遍一般。

所以我的做法是:关键点定义改成猫脸专用的 5 点——左眼中心、右眼中心、鼻头、左耳根、右耳根。这 5 个点在猫脸上相对稳定,受毛发影响小,而且足以支撑仿射变换做对齐。检测模型用的是一阶段目标检测器改造,只保留“猫脸”一个类别,不分类别、不加属性,把问题简化到极致。

1.3 端到端架构分层

整套系统的分层我理成了四层,理解了这个分层后面所有代码都能对上号。

感知层就是摄像头和补光灯。摄像头负责出图,如果要做防伪还得加红外或双目。补光灯很关键,晚上猫脸全是噪点,模型再强也白搭。

边缘推理层是 Android 主板上的 App,包含相机采集、图像预处理、检测、对齐、特征提取、比对决策这一整条流水线。这层是整个项目的核心,也是最耗性能的地方。

决策执行层负责把“识别通过”这个结果变成物理动作。我的做法是双通道:主通道走 MQTT 下发到云端记录,次通道走串口或蓝牙直接给下位机(ESP32)发指令开锁。为什么双通道,后面第 4 章会详细说,简单讲就是网络不可靠的时候,本地串口能兜底,不能让主子在门口干等。

云平台层是可选的,用来存进出记录、远程查看抓拍图、远程下发阈值。我个人的原则是:识别逻辑必须在本地,云端只做记录和配置,绝不让云端参与实时决策。

2. 硬件选型与开发环境的准备工作

2.1 主板、摄像头、执行机构怎么挑

主板这块我前后用过三种,各自的体感差别很明显。

主板类型典型型号推理速度(112x112)价格区间优点缺点
旧安卓手机骁龙 855 级别15~30ms300~600 元便宜、性能强、自带电池散热差、长期插电有鼓包风险
国产开发板RK3399 / RK358820~50ms400~1200 元接口丰富、可定制系统系统适配需要折腾
高通开发板骁龙 6/8 系10~25ms800~2000 元NPU 支持好贵、开发门槛高

如果只是自己家用、图省事,旧安卓手机真的是性价比之王,性能足够、系统成熟、连屏幕都省了。但要注意两个问题:一是散热,手机长期插电跑推理,背板温度能到 60 度以上,得加个小风扇或者铝制散热片;二是电池,长期满电插着对锂电池不友好,理想情况是把电池拆掉直接供 5V,不过这需要一点动手能力。

摄像头我强烈建议用UVC 免驱 USB 摄像头,不要一上来就搞 MIPI CSI。MIPI 的带宽和延迟确实更好,但它需要厂商提供匹配的内核驱动,换一块板子就可能不认,调试周期能从一天变成一周。UVC 摄像头插上就能用,720p 30fps 对猫脸识别完全够用——反正模型输入才 112x112,你给 4K 也没用。

提示:选 UVC 摄像头时注意看是否支持免驱的 MJPEG 和 YUY2 两种格式。MJPEG 带宽占用低但解码要 CPU 开销,YUY2 带宽高但免解码,如果主板 USB 口带宽紧张,优先 MJPEG。

执行机构分两类。舵机适合轻负载的推拉门闩,5V 供电,用 PWM 控制角度,简单但力气小;电磁锁力气大、响应快,但要 12V 独立供电,得加继电器模块。我最后用的是 12V 电磁锁 + 继电器,Android 只负责发一个高低电平信号,具体驱动交给下位机。

下位机建议加一块,ESP32 或者 STM32 都行,成本不到 30 块。它的作用有三个:一是隔离电源,锁的瞬时电流很大,直接挂在 Android 板子的 USB 上容易把设备拉复位;二是做 PWM 和限位检测;三是在 Android 重启的几十秒里维持门锁的默认状态,不至于门开着没人管。

2.2 Android Studio 环境准备与踩坑点

环境这块我列一下我实际的配置和几个容易卡住的地方。

  • Android Studio 版本:用最新的稳定版就行,别追预览版。中文语言包可以装,但对调试帮助不大,命令行输出的日志还是英文,我更建议直接用英文环境。
  • SDK 版本:compileSdk 用 34,minSdk 建议 26 以上。低于 24 的话 Camera2 的一些行为和前台服务限制会让代码写得很别扭。
  • NDK:只有用 TensorFlow Lite 的 C++ API 或者自己写 JNI 预处理时才需要装,纯 Java/Kotlin 调 TFLite 的 Java API 不需要。
  • Gradle 版本冲突:这是新手最容易卡住的地方。TFLite 的 AAR 对 Gradle 版本有要求,如果报Unable to find suitable ...之类的错,先把 Gradle Plugin 升到和 Android Studio 匹配的版本,再检查repositories里有没有加mavenCentral()
  • 无线调试:板子装好之后基本不会再拆下来,用 USB 调试很麻烦。Android 11 以上可以用无线调试配对,或者用adb tcpip 5555切到网络模式。这一步能省掉大量插拔线的时间。
  • 自启动:如果是定制板,把 App 加到系统白名单,避免被后台限制杀掉。普通手机需要在设置里手动允许自启动和后台运行。

注意:如果主板是定制的 Android 系统,很多厂商会默认开启省电策略,把后台服务冻住。要么把 App 做成系统应用,要么在设置里逐项放开限制,否则跑几个小时服务就没了。

2.3 摄像头接入:Camera2 和 CameraX 怎么选

Camera2 是底层 API,控制粒度细但代码量巨大,光是写一个预览 + 取帧就要两三百行。CameraX 是 Jetpack 封装,几行代码就能跑起来,而且它的ImageAnalysis用例天然就是给分析场景设计的。我最终选了 CameraX,理由是识别场景不需要手动控制曝光和对焦——反而自动的更好,猫脸明暗变化大,自动曝光比手动调参省心多了。

val analysis = ImageAnalysis.Builder() .setTargetResolution(Size(640, 480)) .setBackpressureStrategy(ImageAnalysis.STRATEGY_KEEP_ONLY_LATEST) .setOutputImageFormat(ImageAnalysis.OUTPUT_IMAGE_FORMAT_RGBA_8888) .build() analysis.setAnalyzer(inferenceExecutor) { imageProxy -> try { val bitmap = imageProxy.toBitmap() val rotated = bitmap.rotate(imageProxy.imageInfo.rotationDegrees) pipeline.submit(rotated) } finally { imageProxy.close() // 这一行如果漏掉,相机会直接卡死 } }

这里有两个参数必须说清楚。分辨率 640x480是我的实测选择:再低猫脸太小检测不到,再高预处理开销上去了收益不明显。背压策略KEEP_ONLY_LATEST是核心,它保证队列里永远只有最新一帧,处理不过来就丢旧帧,宁可丢帧也不能让延迟越积越大——门口排队等开门的时候,延迟比帧率重要得多。

OUTPUT_IMAGE_FORMAT_RGBA_8888这个设置也值得说。默认 CameraX 给你的是 YUV_420_888,你得自己做 YUV 转 RGB,而转换过程非常容易踩坑(后面 5.2 会讲)。直接要 RGBA 虽然多一次内部转换,但省掉了手动处理 stride 和 pixelStride 的麻烦,对稳定性更有利。

3. 猫脸识别流水线:从像素到身份

3.1 检测:先把猫脸框出来

检测这一步我试过两种架构。一种是两阶段:先用 YOLOv8n 检测“猫”这个整体,再把猫头区域裁出来送进猫脸检测器。另一种是一阶段,直接训练一个只输出猫脸框的检测器。两阶段的优点是整猫检测的预训练权重好找、数据好标;缺点是链路长,两只猫贴在一起的时候容易出问题。

最终我用的是一阶段方案,模型基于 YOLOv8n 改的,输入 320x320,输出一个类别。训练数据我大概标了 3000 张,来源是自己拍的 + 公开猫脸数据集筛出来的。标注规则必须统一:框要贴着两只眼睛和鼻头构成的三角形,耳朵可以切一半,胡须不要框进去。这条规则我一开始没定,标了 500 张之后发现前后不一致,只能全部返工,这个亏吃一次就够了。

训练时的 trick 有几个:一是马赛克增强开到 0.5,猫脸小目标多,马赛克增强对小目标帮助很大;二是关闭上下翻转,猫脸上下翻转后耳朵跑到底下,不符合先验;三是色彩抖动开大一点,因为实际部署时光照变化极大,白天阳光直射和晚上补光灯完全是两个分布。

实测下来,320x320 输入的检测器在 RK3399 上单帧大约 25ms,旧手机上 12ms 左右。加上后面的人脸识别模型,整条流水线在 100ms 以内,猫走到门口站一下就能识别完。

3.2 对齐:这一步最容易被跳过,也最要命

检测出框之后,很多人会直接把框里的图缩放到 112x112 就送进识别模型。我一开始也这么干,结果同一只猫换个姿势,相似度能从 0.85 掉到 0.55,直接跌破阈值。

原因很简单:猫的头部姿态变化会让眼睛、鼻子在图像里的相对位置发生剧烈位移,模型没有空间不变性,它学到的是“像素在哪个位置”,而不是“这只猫的鼻子长什么样”。所以对齐这一步不能省

具体做法是用前面提到的 5 个关键点做仿射变换。关键点检测器我是在检测模型上加了一个分支,共享主干,输出 5 组坐标加置信度。然后定义一个标准模板:

# 112x112 输入下的猫脸标准模板坐标 TEMPLATE = np.array([ [38.3, 51.7], # 左眼中心 [73.7, 51.7], # 右眼中心 [56.0, 71.7], # 鼻头 [25.0, 32.0], # 左耳根 [87.0, 32.0], # 右耳根 ], dtype=np.float32)

cv2.estimateAffinePartial2D求出变换矩阵,把实际关键点映射到模板位置,再做一次裁剪。之所以用Partial2D而不是完整的Affine2D,是因为它只允许旋转、缩放和平移,不允许剪切和拉伸,能避免猫头侧偏时把脸拉变形。

对齐做完之后,同猫不同姿态的相似度能稳定在 0.75 以上,这个提升非常显著。代价是每帧多了大概 3ms 的关键点检测和 1ms 的仿射变换,完全值得。

3.3 特征提取:MobileFaceNet 微调与数据准备

识别模型我用的 MobileFaceNet,就是把 MobileNetV2 的瓶颈模块换成更适合人脸的结构,输入 112x112,输出 128 维特征向量。选它是因为模型只有 1M 出头的参数量,在 Android 上跑得飞快,而且社区里有大量成熟的训练脚本可以参考。

训练数据是这件事成败的关键。每只猫我建议至少准备 200 张,理想是 500 张,覆盖这些维度:

  • 正脸、左侧 45 度、右侧 45 度、俯视、仰视
  • 白天自然光、晚上开灯、晚上只开补光灯
  • 睁眼、眯眼、打哈欠、刚睡醒
  • 背景干净和背景杂乱

数据不够怎么办?我的做法是用同一只猫的视频抽帧,抽的时候按相似度去重,避免几百张几乎一样的图把数据分布搞偏。另外可以适度做数据增强:随机亮度对比度扰动、小角度旋转(±15 度以内)、随机遮挡(模拟被爪子挡脸),但不要做水平翻转,原因和检测那块一样。

损失函数用 ArcFace,超参上有个细节:人脸识别常用的 m=0.5 是在百万级身份数据上调出来的,猫脸数据集通常只有几只到几十只身份、每类几百张,这个 margin 太大,训练很容易不收敛。我实测 m 在 0.3~0.35 比较稳,缩放因子 s 保持 64 不变。如果发现 loss 在早期就震荡,先把 m 降到 0.25 跑几十个 epoch 再往回升。

导出 TFLite 的时候有个取舍:

converter = tf.lite.TFLiteConverter.from_saved_model("catface_saved_model") converter.optimizations = [tf.lite.Optimize.DEFAULT] # 方案一:float16 量化,体积减半,精度几乎无损 converter.target_spec.supported_types = [tf.float16] tflite_model = converter.convert()

我对比过三种量化方式:float32模型 4.8MB,精度最高但速度慢;float16模型 2.4MB,精度掉不到 0.5%,速度提升 20% 左右,这是我的首选;INT8模型 1.2MB,速度快一倍,但需要代表性数据集做校准,而且猫脸这种细粒度特征对量化误差很敏感,我实测精度掉了 3 个百分点,误识率上升明显,最后没用。

3.4 比对决策:阈值不是拍脑袋定的

特征向量出来后做 L2 归一化,然后和库里已注册的向量算余弦相似度,取最大值作为匹配分数。这里最关键的问题是:阈值定多少

我的方法是准备两组相似度分布:5000 对“同一只猫”的样本对,5000 对“不同猫”的样本对。分别算相似度画成直方图,两个分布的交叠区域就是误差区。选一个阈值,让误接受率(把别的猫认成自家猫)和误拒绝率(自家猫认不出来)大致相等,这就是等错误率点。

阈值误接受率(异猫放行)误拒绝率(自家猫被拒)适用场景
0.45约 3.2%约 1.1%只是记录进出,不作为门禁
0.50约 1.1%约 2.4%一般门禁,可接受偶尔重试
0.55约 0.3%约 5.8%安全优先,猫要多站两秒
0.60约 0.05%约 12%极高安全要求,需配合多帧投票

我最后定在0.52,再加上一个多帧一致性投票:连续 3 帧都判定为同一身份且相似度都超过阈值,才真正触发开锁。这个机制把单帧的偶发误判压下去一个数量级,代价是触发延迟增加大概 100ms,猫根本感知不到。

另外还有一个细节:注册样本数量少的时候阈值要往上调。如果你只给猫录了 10 张图,库里的特征分布覆盖不全,同一只猫在不同角度下的相似度波动会很大。这种情况下我会把阈值临时提到 0.58,并且建议用户补录到 30 张以上。

4. Android 端工程落地:推理、通信与联动

4.1 TFLite 推理封装与加速开关

TFLite 的 Java API 用起来很简单,但加速配置有几个坑。GPU Delegate 能显著提速,但首次加载有几百毫秒的初始化时间,绝对不能放在主线程,否则直接 ANR。另外 GPU Delegate 对某些算子不支持,遇到不支持的算子会静默回退到 CPU,你如果不看日志根本不知道自己在跑 CPU。

class CatFaceInterpreter(context: Context) { private val interpreter: Interpreter init { val options = Interpreter.Options().apply { numThreads = 4 // 优先尝试 NNAPI,失败再试 GPU try { addDelegate(NnApiDelegate()) } catch (e: Throwable) { Log.w(TAG, "NNAPI 不可用,回退 GPU", e) addDelegate(GpuDelegate()) } setUseXNNPACK(true) } val model = FileUtil.loadMappedFile(context, "catface_fp16.tflite") interpreter = Interpreter(model, options) } fun infer(input: ByteBuffer, output: Array<FloatArray>) { interpreter.run(input, output) } }

我实测下来,同一个 float16 模型在 RK3399 上:纯 CPU 4 线程 42ms,GPU Delegate 18ms,NNAPI 22ms。GPU 最快但兼容性最差,NNAPI 居中,CPU 最稳。我的策略是启动时按 NNAPI → GPU → CPU 的顺序探测,探测失败就往下走,并且把最终用的后端打日志出来,方便排查。

4.2 线程模型:别让主线程背锅

相机回调线程、推理线程、UI 线程必须是分开的,而且推理线程只能有一个。为什么?因为 GPU Delegate 不是线程安全的,多个线程同时调用同一个 Interpreter 会直接崩溃,而且多线程抢 GPU 反而更慢。

我的线程模型是这样的:相机分析器运行在一个单线程 Executor 上,它只负责把 Bitmap 丢进一个有界 Channel;推理线程从这个 Channel 取数据,容量设成 1 并且策略是丢弃最旧的;推理结果通过 Handler 抛回主线程更新 UI。

private val frameChannel = Channel<Bitmap>(capacity = 1, onBufferOverflow = BufferOverflow.DROP_OLDEST) // 推理循环 scope.launch(Dispatchers.Default) { for (frame in frameChannel) { val result = pipeline.process(frame) frame.recycle() withContext(Dispatchers.Main) { ui.update(result) } } }

这里frame.recycle()很重要,Bitmap 不回收的话跑几个小时必 OOM。另外 Channel 的容量千万不要设大,设成 5 或者 10,你会看到延迟线性增长,最后变成“猫走了三秒门才开”。

4.3 注册流程:怎么让主子配合你采 20 张图

注册是最难的环节,不是技术难,是猫不配合。我的做法是分两步引导:先用逗猫棒或者零食把猫引到摄像头前,App 界面上放一个进度条显示已采样本数,采满自动结束,采的过程中实时显示检测框和当前质量评分。这样你能知道系统到底有没有在正常工作,而不是傻等。

采样的质量控制有三个硬指标,不达标直接丢弃并且不计数:

  • 清晰度:用拉普拉斯算子算方差,低于 100 判定为模糊,通常是猫在动或者对焦没对上
  • 框面积:猫脸框面积小于整图的 5% 就丢弃,太远了特征提取不准
  • 关键点置信度:5 个关键点平均置信度低于 0.8 丢弃,通常是侧脸太偏或者被遮挡

保存的时候,图片放在私有目录里,文件名带上时间戳和哈希。如果想把抓拍图导出给同事看效果,用 FileProvider 生成一个content://的 URI 丢给分享组件,不要直接给文件路径——Android 7.0 之后直接传file://路径会抛FileUriExposedException,这个坑几乎每个人都会踩一次。

<provider android:name="androidx.core.content.FileProvider" android:authorities="${applicationId}.fileprovider" android:exported="false" android:grantUriPermissions="true"> <meta-data android:name="android.support.FILE_PROVIDER_PATHS" android:resource="@xml/file_paths" /> </provider>
<!-- res/xml/file_paths.xml --> <paths> <files-path name="captures" path="captures/" /> <cache-path name="logs" path="logs/" /> </paths>

4.4 决策与执行:MQTT 加串口的双通道设计

识别通过之后要开锁,我把指令下发做成了双通道。主通道是 MQTT,发给自建的 broker,同时把这次开门记录写到云端;备通道是串口(或者 BLE),直接连下位机。

为什么非要双通道?因为家庭 Wi-Fi 的稳定性远不如你想象。我实测过,路由器重启、微波炉工作、手机大量下载的时候,MQTT 的心跳都可能断。如果只有 MQTT 一条路,网络一抖猫就被关在门外,这个体验无法接受。串口是物理连接,只要线没掉就一直可用。

val payload = JSONObject().apply { put("deviceId", deviceId) put("action", "UNLOCK") put("identity", matchedCatId) put("score", score) put("ts", System.currentTimeMillis()) put("nonce", UUID.randomUUID().toString()) } // 计算 HMAC 签名,防止重放 payload.put("sign", hmacSha256(payload.toString(), deviceSecret)) mqttClient.publish("catdoor/$deviceId/cmd", payload.toString().toByteArray(), 1, false) // 300ms 内没收到回执,走串口兜底 handler.postDelayed({ if (!ackReceived) { serialPort.write("OPEN:$nonce\n".toByteArray()) } }, 300)

指令里加nonce 和 HMAC 签名是必须的。MQTT 如果没有开 TLS,报文在网络里是明文的,别人伪造一条UNLOCK就能开门。做法是用设备密钥对关键字段做一次 HMAC-SHA256,下位机自己验签,验不过就丢弃。nonce 保证同一条指令不能被重放。

4.5 7x24 运行的稳定性打磨

这是最容易被忽视但最影响体验的部分。我的设备连续跑了一个月,中间遇到各种问题,总结下来做了这几件事。

前台服务 + START_STICKY:Android 8.0 之后后台服务很容易被杀,必须用前台服务并挂一个常驻通知。返回标志用START_STICKY,被杀之后系统会尝试重建。

看门狗:启动一个定时任务,每 10 秒检查一次最近一次推理的时间戳。如果超过 30 秒没有产出任何结果,说明相机或推理线程卡住了,直接重建整条流水线。

温度控制:读取/sys/class/thermal/thermal_zone0/temp,超过 70 度把分析帧率从 15fps 降到 5fps,低于 60 度再恢复。这一步能显著降低夏天死机概率。

存储清理:抓拍图存着存着就把空间占满了。我用 FileObserver 监听存储目录,同时在每次启动时检查剩余空间,低于 500MB 就清理 7 天前的抓拍图。注意别在主线程做遍历,几千个文件的 stat 操作能让界面卡死。

Wi-Fi 强度检查:部署位置定下来之前,读一次WifiManager.getConnectionInfo().getRssi(),低于 -70dBm 就换个位置或者加个中继。信号弱的时候 MQTT 会频繁重连,日志里全是错误,排查起来很痛苦。

熄屏与唤醒锁:屏幕关掉省电,但 CPU 不能睡。用PARTIAL_WAKE_LOCK保持 CPU 运行,别用FULL_WAKE_LOCK,那个会同时点亮屏幕,非常耗电。

5. 常见问题与排查实录

5.1 识别不准,按这个顺序排查

遇到识别不准,不要一上来就怀疑模型,按下面的顺序往下查效率最高。

现象可能原因验证方法处理方式
白天正常,晚上全废光照不足导致噪点保存晚上的抓拍图看画质加 850nm 补光灯,或加宽动态摄像头
正脸准,侧脸不准训练数据侧脸太少统计训练集姿态分布补采 45 度、60 度侧脸样本
自家猫有时识别不到关键点对齐失败打印关键点置信度置信度低于 0.8 直接丢弃整帧
两只猫互相误认毛色花纹接近看两只猫的相似度分布采更多样本,阈值上调到 0.58
隔着玻璃识别不到玻璃反光用手机拍一张看反光摄像头贴紧玻璃,加遮光罩
猫刚睡醒识别不到眯眼导致关键点错位看那一帧的关键点可视化多帧投票,靠其他帧兜底

注意:排查的时候一定要把中间结果可视化出来——画出检测框、标出关键点、打印相似度。靠猜是最慢的排查方式。

5.2 图像方向和颜色不对

这两个问题是 Camera 开发里最经典的坑,我第一次做的时候全都踩了一遍。

方向不对:通常是预览是正的、喂给模型的图是横的。原因是你没有处理ImageInfo.getRotationDegrees()。CameraX 已经帮你算好了,你只需要在拿到 Bitmap 之后按这个角度旋转一次。UVC 摄像头的旋转角度可能是 90 也可能是 270,取决于它是正装还是倒装,所以要允许配置。

颜色发绿或者发紫:这是 YUV 转 RGB 时的问题。YUV_420_888 有三个平面,其中 U 和 V 平面是交错的,pixelStride可能是 1 也可能是 2,rowStride也可能大于宽度。如果你直接用宽度去遍历,边界像素就会错位,表现为图像边缘有一条绿色的边或者整体偏色。解决方案有两个:一是直接用OUTPUT_IMAGE_FORMAT_RGBA_8888让系统转;二是用 LibYUV 库,它内部处理了各种 stride 情况,比自己写可靠得多。

5.3 内存、崩溃和 ANR

跑了三天崩一次,多半是内存问题。我的经验是三个地方必须盯死。

Bitmap 复用:绝对不要在每一帧里Bitmap.createBitmap,那个开销和 GC 压力都非常大。用BitmapPool或者自己维护一个固定尺寸的缓冲池,用完 recycle 归还。

ImageProxy 必须 close:这个在前面代码里强调了,但还是要再说一遍。漏掉 close 的后果不是崩,是相机直接停止出帧,而且不报错,你会以为是推理卡住了,查半天查不出来。

ANR:主线程只做 UI 更新,所有图像处理都在后台线程。特别注意 JSON 序列化、文件写入、SharedPreferences 首次加载这几个操作,它们都可能耗时超过 100ms 从而触发 ANR。

5.4 网络与指令可靠性的坑

MQTT 的断线重连要开automaticReconnect,但光开这个不够,还要处理重连之后的订阅恢复。我用的是 cleanSession=false,这样 broker 会保留会话,重连之后自动恢复订阅,省掉手动重订阅的代码。

消息重复投递是另一个常见问题。QoS 1 保证至少一次,意味着同一条开锁指令可能被下发两次。做法是在下位机侧维护一个最近 10 个 nonce 的缓存,重复的直接丢弃。

时间同步也值得提一句。如果下位机要做"每天几点之后不开门"这类策略,两边时间必须一致,Android 侧定期把时间同步给下位机,避免它重启后时间归零。

5.5 防伪:照片和玩偶能不能骗过系统

这是一个在实际使用中会遇到的问题。我做过测试,拿手机的猫照片、平板显示的照片、甚至一个毛绒猫玩具放在摄像头前,早期的系统确实被骗开过一次。所以如果这套系统真的是当门禁用,防伪必须做。

方案原理额外成本防伪强度实现难度
微动检测连续帧关键点位移,照片位移为 0
双目测距计算猫脸距离,贴脸照片会露馅加一个摄像头
红外补光屏幕和照片的红外反射特性不同红外灯 + 无 IR-cut 镜头
纹理分析照片有摩尔纹和镜面反光中低
ToF 深度直接获取深度图极高

我最后用的是微动检测 + 双目测距的组合。微动检测成本为零,只要连续 5 帧的关键点平均位移超过 2 个像素就认为是活体,静态照片直接过不了。双目测距用两个便宜摄像头,标定一次之后算视差,猫脸距离在 20~60cm 之间才放行。这两个加起来把误开的概率压到了可接受范围。

6. 调优经验与后续可扩展的方向

6.1 我总结的参数调优速查表

调参这件事没有银弹,但有几个关键旋钮,改对了收益最大。我把它们整理成一张表,方便对照调整。

参数我的取值调大的影响调小的影响
检测输入尺寸320x320小目标更准,速度变慢速度快,远距离猫脸漏检
分析分辨率640x480远处猫脸更清楚,CPU 上升CPU 低,但小脸检测难
相似度阈值0.52更安全,误拒增加更宽松,误接受增加
多帧投票帧数3误判更低,延迟增加响应快,偶发误判变多
分析帧率上限15fps触发更快,发热增加省电,猫要多站一会儿
温度降频阈值70 度更凉快,识别变慢性能全开,有死机风险
特征向量维度128区分度略好,内存增加省内存,细粒度区分变弱

调参的优先级我建议是:先把对齐打通,再调阈值,最后调性能参数。顺序反了会浪费大量时间——对齐没做好的时候,你怎么调阈值都是在两个糟糕的分布之间找平衡点。

6.2 这套东西还能往哪扩

项目跑通之后,能扩展的方向其实挺多的,我列几个我觉得有意思并且实现成本不高的。

多猫管理:现在库里存的是单只猫的特征,改成一人一档的注册表就行,每只猫存多个特征向量(不同角度的均值或者聚类中心),识别时取最大相似度。要注意的是猫的数量超过 5 只之后,误识率会上升,需要把阈值相应上调。

进食量统计:如果同时接了喂食器,可以记录每次进食的时间和持续时长,设备上显示"今天吃了 4 次,共 12 分钟"。数据攒一段时间之后能看出规律,猫突然不吃东西是很重要的健康信号。

健康监测雏形:通过抓拍图做一些简单的行为分析,比如连续多天在同一个位置趴着不动、进食次数下降,这些都可以作为提醒。注意这里只是辅助观察,任何健康判断都应该交给专业人士,不要过度解读。

远程查看:把抓拍图压缩后同步到自建服务器,手机上随时看。这里要注意隐私,图片传输必须加密,存储也要控制保留期。

我个人在实际操作中的体会是:这类项目的难点从来不在模型本身,而在那些看起来不起眼的工程细节——相机有没有正确关闭、Bitmap 有没有回收、网络断了之后能不能兜底。模型准确率从 95% 提到 97% 要花几周,但把泄漏的 ImageProxy 修掉只要一行代码,收益却是从"跑三天必崩"变成"稳定跑一个月"。如果你是第一次做边缘 AI 落地,我建议先把整条链路的稳定性跑通,哪怕识别率只有 80%,只要能连续跑一周不崩,你就已经跨过了最难的那道坎,剩下的都是在这个基础上慢慢调优的事。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询