核心问题:当 YOLO、人脸、人手三个模型同时跑在一条手机摄像头流上,怎么让快的检测器不被慢的拖死,让该响的警报一秒都不迟到?

第一次真机测试:警报迟到了八秒

凌晨一点,把 APK 装到真机上,对着摄像头闭眼。预期:闭眼 1.2 秒后疲劳警报响起。实际:我闭着眼睛数了快十秒,屏幕上的"闭眼时长"读数还在几百毫秒徘徊,警报才不情不愿地响起来。

第一反应是推理太慢。但看监控数据完全不是这么回事——MediaPipe 人脸关键点单帧只要几毫秒,YOLO 手机检测也就三五十毫秒,加起来远够不到十秒。问题出在别处。

这个项目是个"AI 检测助手":一条 CameraX 取流管线,两种模式——车损分析(后摄,YOLO 检测车辆)和驾驶模式(前摄,同时跑 YOLO 手机检测 + MediaPipe 疲劳检测 + 手部验证)。从 23:30 的 init 提交到 00:45 的最后一个提交,75 分钟里踩的每一个坑,都值得记下来。

一条管线,三个检测器,谁也不许拖累谁

先交代架构。整个 App 只有一个 ImageAnalysis 分析器,模式切换只是改分发分支,不重绑相机——只有手动切前后摄才重新 bind。

graph LR A[CameraX 取流<br/>RGBA_8888] --> B[转 Bitmap] B --> C{YOLO 手机类} B --> D{FaceLandmarker<br/>疲劳} B --> E{HandLandmarker<br/>按需} C --> F[叠框 + 状态机] D --> F E --> F F --> G[声音警报 / 叠框]

几个值得说的技术选择:

  • 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 拉警报。

graph TD A[闭眼分数 > 阈值] --> B[累计闭眼时长] B --> C{时长 >= 1.2s} C -->|是| D[DROWSY 警报] C -->|否| B A -->|否| E{低于阈值<br/>超过 300ms?} E -->|未超过, 视为抖动| B E -->|超过| F[清零计时]

问题藏在"分数掉回阈值以下就清零"这个朴素逻辑里。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(满强度)设计意图
触发间隔2600ms1000ms越拖越密
哔声次数35序列变长
哔声间隔220ms130ms节奏变急
语音播放速率1.0x1.4x音调升高
振动时长250ms150ms间隔密了,时长反而要短

关键在 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
警报迟到 ~10sblendshape 分数抖动反复清零计时器300ms 重开宽限 + 400ms 人脸丢失宽限
闭眼瞬间检测掉线MediaPipe 置信度门限 0.5 太严降到 0.4
语音"结尾重复"杂音同 clip 叠播play 前先 stop + 1000ms 节流下限
灵敏度滑杆方向反直觉阈值越低越灵敏,与"向右=更灵敏"相反UI 层换算成 0~100 灵敏度百分比

几个可以带走的东西

  1. 多路异构推理:per-detector gate,不是全局 gate。 慢的永远不该拖慢快的。
  2. 阈值状态机必须有宽限窗口。 信号有噪声时,grace window 是正确性的一部分,不是锦上添花。
  3. 警报要对抗习惯化。 连续升级(progress²)优于固定分档,双刺激交替优于单一重复。
  4. 端到端 demo 的价值在于验证管线,不在于模型效果。 把模型和代码解耦(shape-only contract),后面换模型零成本。
  5. 下一步:真车损数据集微调 YOLO26,替换模型资产;顺手把置信度阈值和叠框对齐做成可调项。