核心问题:当 YOLO、人脸、人手三个模型同时跑在一条手机摄像头流上,怎么让快的检测器不被慢的拖死,让该响的警报一秒都不迟到?
第一次真机测试:警报迟到了八秒
凌晨一点,把 APK 装到真机上,对着摄像头闭眼。预期:闭眼 1.2 秒后疲劳警报响起。实际:我闭着眼睛数了快十秒,屏幕上的"闭眼时长"读数还在几百毫秒徘徊,警报才不情不愿地响起来。
第一反应是推理太慢。但看监控数据完全不是这么回事——MediaPipe 人脸关键点单帧只要几毫秒,YOLO 手机检测也就三五十毫秒,加起来远够不到十秒。问题出在别处。
这个项目是个"AI 检测助手":一条 CameraX 取流管线,两种模式——车损分析(后摄,YOLO 检测车辆)和驾驶模式(前摄,同时跑 YOLO 手机检测 + MediaPipe 疲劳检测 + 手部验证)。从 23:30 的 init 提交到 00:45 的最后一个提交,75 分钟里踩的每一个坑,都值得记下来。
一条管线,三个检测器,谁也不许拖累谁
先交代架构。整个 App 只有一个 ImageAnalysis 分析器,模式切换只是改分发分支,不重绑相机——只有手动切前后摄才重新 bind。
几个值得说的技术选择:
- YOLO26 是 NMS-free 的。输出直接是
[1,300,6](x1,y1,x2,y2,conf,cls),模型内部自己做完了去重和按置信度排序,设备端省掉一整层 NMS 后处理——比 YOLOv8 少一个步骤,少一份出错面。 - letterbox 往返:原图等比缩放 + 灰边填充到 640×640 → 推理 → 坐标按 scale/pad 映射回原图空间。
CarDamageDetector只认输入输出 shape,跟识别什么类别无关——这是后面"换模型不换代码"的基础。 - CameraX 直接输出 RGBA_8888,不用手写 YUV→RGB 转换,
ImageProxy转 Bitmap 十几行搞定。 - 模型资产在 gradle 里配了
noCompress,保持未压缩方便 ONNX Runtime 内存映射。
第一版的问题出在并发控制上。
快被慢拖死:单一闸门的"车队效应"
最初的实现里,三个检测器共用一个 AtomicBoolean 做丢帧闸门:谁先抢到帧谁跑,其他的直接丢。看起来公平,实际是个坑——MediaPipe 的人脸跟踪只要几毫秒,而 YOLO 一趟要几十毫秒。共用一把锁的结果是:快的检测器永远要等慢的跑完才能轮到自己,人脸框在屏幕上滞后好几秒,看起来像根本没在跟踪。
这就像收费站只有一个车道,一辆货车(YOLO)堵住后面所有小车(MediaPipe),明明小车能先走。
修法很朴素:每个检测器一把独立的锁。Bitmap 转换便宜,每帧都转;各检测器自己 compareAndSet 判断这帧要不要——慢的 YOLO 占着帧的时候,快的人脸照样每帧都在跑。改完人脸框立即恢复实时。
这个原则可以带走:多路异构推理并存时,别用全局闸门,让快的永远不被慢的拖住。
警报迟到的真相:计时器被噪声反复清零
回到开头那个 10 秒警报。推理速度没问题,那问题在哪?
疲劳检测是个简单的阈值状态机:MediaPipe FaceLandmarker 输出 eyeBlinkLeft/eyeBlinkRight blendshape 分数(Google 校准过的表情系数,比手搓 EAR 眼宽比更稳),取平均当"闭眼分数",超过阈值就开始累计闭眼时长,累计到 1.2 秒判 DROWSY 拉警报。
问题藏在"分数掉回阈值以下就清零"这个朴素逻辑里。blendshape 分数是逐帧抖动的——眼睛确实闭着,但某一帧分数抖到阈值下,计时器当场清零。于是"闭眼时长"永远累计不到 1.2 秒:你闭眼 10 秒,计时器被清零了十几次,警报当然迟到。
修复是引入 EYE_REOPEN_GRACE_MS = 300ms 的宽限期:分数低于阈值,但只要 300ms 内又回到闭眼状态,就当作抖动,不清零。配套还有 FACE_LOST_GRACE_MS = 400ms:人脸短暂丢失(比如闭眼瞬间 landmark 置信度骤降)也不清空计时。
另一个隐藏元凶:MediaPipe 的 minFaceDetectionConfidence 等三个置信度阈值原本是 0.5。跟踪门限越严,越容易在闭眼瞬间掉线——恰恰是最需要连续性的时刻。降到 0.4 之后,闭眼瞬间的检测断档明显减少。
这个坑值得单独拎出来:阈值状态机里,“grace window"不是优化,是必需品。任何基于阈值的时序判断,只要信号有噪声,就一定要有滞回或宽限,否则状态永远在边界上反复横跳。
“手机在手里"还是"手机在支架上”?
驾驶模式里,YOLO 检出 cell phone 类只是第一步——手机放支架上导航,和拿在手里打电话,都是"检测到手机”。所以加了一个可选的手部验证:HandLandmarker 跑出人手框,和手机框做重叠判断,只有真被握持才算分心。
两个实现细节值得记:
- 手部关键点框比视觉上的手小——关键点只覆盖指尖到手腕,松握的手机可能刚好处在框外。判断前先把手机框外扩 12% 再求交集。
- 按需分发:手部检测只在"最近一次 YOLO 结果非空"的帧上派发。没有手机可验证的时候,第三个 MediaPipe 任务每帧空转纯属浪费电。
警报设计:对抗"习惯化"
警报系统有个反直觉的问题:固定模式的警报会被大脑习惯化。同一个哔声重复 20 次,人就听不见了——对"司机真的要睡着了"这个场景,方向完全反了。
所以警报做成了连续升级(escalating)而不是固定档位:
| 参数 | 闭眼 1.2s(起始) | 闭眼 10s(满强度) | 设计意图 |
|---|---|---|---|
| 触发间隔 | 2600ms | 1000ms | 越拖越密 |
| 哔声次数 | 3 | 5 | 序列变长 |
| 哔声间隔 | 220ms | 130ms | 节奏变急 |
| 语音播放速率 | 1.0x | 1.4x | 音调升高 |
| 振动时长 | 250ms | 150ms | 间隔密了,时长反而要短 |
关键在 progress = linear²:前几秒强度几乎不变,最后几秒陡升——镜像真实紧迫感的曲线,而不是三段式"平台期"(每个平台期本身又是一种可学习的固定节奏)。
每轮触发是"哔哔哔"先导音 + warning.mp3 真人语音剪辑,两种刺激交替出现,比单一刺激重复更难被习惯化;真人录音也比合成哔声更能穿透疲劳状态。
这里还有个小坑:SoundPool 不 stop() 直接 play() 同一个 clip 会叠音,产生"听到结尾重复"的杂音——播放前先停掉上一次。另外整套"哔声+语音"序列有真实长度,节流下限设到 1000ms,否则下一次触发会砍掉上一次的尾巴。
叠框的偏执:从矩形框到轮廓线
人脸叠框最初是 bounding box,后来全换成了 MediaPipe FaceMesh 的轮廓线(脸椭圆 + 双眼轮廓,按官方 index 集合成闭合折线)。视觉上更接近"真的在跟踪人脸"。
但轮廓线有个问题:原始 landmark 贴皮肤边缘太紧,画出来比人眼感知的"眼睛大小"小一圈。修法是以轮廓质心为中心外扩(脸 1.15 倍,眼睛横向 2.2 倍 / 纵向 1.5 倍)——但横向外扩后,左右眼轮廓在鼻梁处碰上了。又加了一道"中线 keep-out":以两眼中点为中线,各眼轮廓只允许向外侧(鬓角方向)膨胀。
还有个顺手的好处:眼睛轮廓颜色随判定状态翻转(睁眼绿 / 判闭红),调灵敏度滑杆时,阈值触发的那一帧肉眼可见——调试体验直接拉满。
老实说:这不是真的车损检测
必须诚实:yolo26n.onnx 是 Ultralytics 官方 COCO 预训练权重,只能识别 car/truck/bus/motorcycle 这种整体类别,认不出划痕、凹陷、裂纹。COCO 里根本没有车损标签。
这是个刻意的取舍——demo 验证的是端到端检测流水线(取流 → 前处理 → 推理 → 坐标映射 → 叠框)跑得通、够快,而不是模型效果。因为 CarDamageDetector 只依赖输入输出 shape,真要做车损检测:收集标注车损数据集 → yolo train 微调 → 导出 ONNX → 替换 assets/yolo26n.onnx + CocoLabels,Kotlin 代码一行不用改。
另外有两个"已知简化"写在代码注释里,别随手"修":OverlayView 假设 PreviewView 是 fillCenter 且和分析流同宽高比(像素级对齐需要处理中心裁切偏移);置信度阈值 0.35 硬编码,换自己训练的模型时应该做成可调参数。
调试清单:75 分钟踩过的坑
| 现象 | 根因 | 修复 |
|---|---|---|
| 人脸框滞后数秒 | 共用全局闸门,快检测器被慢的 YOLO 拖住 | 每检测器独立 AtomicBoolean |
| 警报迟到 ~10s | blendshape 分数抖动反复清零计时器 | 300ms 重开宽限 + 400ms 人脸丢失宽限 |
| 闭眼瞬间检测掉线 | MediaPipe 置信度门限 0.5 太严 | 降到 0.4 |
| 语音"结尾重复"杂音 | 同 clip 叠播 | play 前先 stop + 1000ms 节流下限 |
| 灵敏度滑杆方向反直觉 | 阈值越低越灵敏,与"向右=更灵敏"相反 | UI 层换算成 0~100 灵敏度百分比 |
几个可以带走的东西
- 多路异构推理:per-detector gate,不是全局 gate。 慢的永远不该拖慢快的。
- 阈值状态机必须有宽限窗口。 信号有噪声时,grace window 是正确性的一部分,不是锦上添花。
- 警报要对抗习惯化。 连续升级(progress²)优于固定分档,双刺激交替优于单一重复。
- 端到端 demo 的价值在于验证管线,不在于模型效果。 把模型和代码解耦(shape-only contract),后面换模型零成本。
- 下一步:真车损数据集微调 YOLO26,替换模型资产;顺手把置信度阈值和叠框对齐做成可调项。