Skip to content

小车真的会跟着我走了——视觉跟随开发实录 ​

上一篇做机械臂遥控器,Agent 替我编译、装机、点屏幕,还盯着摄像头验收机械臂动了。 这一篇接着给同一套小车加"自动驾驶"的第一站——视觉跟随:人不带任何信标,小车靠车上手机认出人,人走哪跟到哪。 代码还是一行没写。我只负责动嘴、当目标走位、测试,反馈问题。

App 端实时 HUD:认出人、量出距离、把指令($MV)下发给车


目录 ​


一、任务:让小车跟着人走 ​

硬件没变,只是手机从"架在对面看机械臂"挪到了车上:

部件干什么用
小车麦克纳姆轮,四个轮子由总线舵机驱动,文本指令控制
控制板ESP32,MicroPython 固件,解析指令、驱动轮子、读超声波
手机安卓真机,固定在车上,摄像头当"眼睛",跑 CheapCam
链路手机蓝牙透传 → ESP32 串口 → 轮子

"跟着人走"听着就一句话,动手才知道它其实问了三件事:人在哪(目标得是人,不能让人身上挂个信标)、多远(太远追上、太近倒退,不能一头撞腿上)、该不该停(任何情况人挡前面必须停,宁可错停)。

第三件最要命,第四节再说怎么兜。前两件,得先选条技术路线。


二、选路线:UWB,我最后放弃了 ​

我认真评估过 UWB:车上装基站,人揣个标签,基站串口直接吐"角度 + 距离",20Hz 刷新,角度 ±3°、距离 30cm。台架实测一次通过,连"负角度怎么编码"这种最容易踩错的地方都拿真实数据钉死了——照抄成无符号就会左右反转,而这是"车要朝人开过去"的模式,反转就是越开越远。

纸面上它比视觉强一个量级:不受光照影响、不占算力、直接给距离(视觉只能靠检测框高度猜)。但我最后把它挂起了,两个原因:

1、我根据ai提供的信息,认为mate80手机已经携带了uwb信标,实际上它根本没法跟我购买的基站互通。

2、另外一种就是手拿着信标,但是供电是个问题,买回来的是2个芯片,基站和信标需要单独供电。 esp32芯片是5V电压给小车上的超声波供电,这个uwb基站要求3v供电,这~~~~~ 另外信标拿手上也没地方供电。

没搞过硬件还是经验不足啊

img.pngimg_1.png


三、对象识别的模型选择 ​

需要考虑一下,yolo是AGPL协议。

模型person(160 帧)手柄(654 帧)真机推理
YOLOX-Tiny@416 fp32100% / 1 次切换99.8%222ms
YOLOX-nano@416 int863% / 21 次 ✗3–27% ✗28ms
YOLO11n@640100%96.6%—

有个意外的坑:中间的 nano,int8 量化把检测力直接毁了,分数从 99.8% 掉到 27%,"换个目标类救一下"也没用——量化伤的是表征本身。所以最终定档 YOLOX-Tiny@416:唯一同时满足"许可干净 + 两个场景都锁得住"的,代价是 222ms 的推理。先保"对",性能优化另开线程。

顺手一个发现:跟随目标不一定要是人。手柄(COCO 里的 remote 类)实测检出分 0.433,比某些"能检出"的类还稳——跟人和跟物体共用一条管线,只换一个类别号。

我以为手机的计算能力应该很强的,怎么也比那些卖玩具跑的识别强吧,实际效果看,没有想象中的强。

在mate30手机上,yolox-256是兼顾检测效率与准确率的最佳选择了。


四、架构:模型只准建议,不准开车 ​

整条决策链长这样:

相机帧(复用推流画面,旁路取帧不卡直播)
     │  YOLOX 检测 + 跟踪
     ▼
感知(目标在画面哪、多大、多新鲜)
     │
控制器.compute()        ← 只算"建议":往哪转、走多快
     │
AEB 仲裁               ← ★ 所有运动来源都得过这道
     │
蓝牙下发 $MV → ESP32
     │
固件安全门            ← 最后一根柱子:锁存时只放行后退/旋转

我一开始就定了两个规矩,最后都写进了代码而不是嘴上说说。

第一,模型输出只是建议。 控制器给的是"我建议往右转、往前走",真拍板的永远是 AEB 仲裁 + 固件安全门。超声波说太近,前向指令当场清零,旋转保留(留脱困能力)。这条规矩覆盖所有运动来源——摇杆、按键、语音、漫游、跟随,谁都不许绕。这不是过度设计:实测真出过"AEB 已经锁存、语音喊请抬手、车还一直往前跑"——因为手动指令从 App 直发,没过任何安检。现在四条发射路径全走同一个判据。

第二,不写假值兜底。 检测框看不到了,绝不能当成"请在原地待命",而是明确进 SEARCHING(原地限时慢转找人)→ 再找不到就 LOST(零向量 + 强制停车)。"跟丢了还按最后一次的位置猛冲"是这类功能最经典的事故,状态机直接把这条路焊死了。


五、眼睛:像素差 1px,检出率差十几倍 ​

模型接上就能用?天真。光"把图喂进模型"这一步就翻了两次。

第一次,letterbox 对齐我照抄了 YOLOX 官方 demo——左上对齐。而 COCO 训练口径是居中。等离线回放发现命中率不对劲,改成居中,其他一律不动,命中率从 88.1% 跳到 99.8%。只差那一圈灰边填在左边还是两边分。

第二次更隐蔽。摄像头出的是 16:9 画面,早期分析画布固定 480×360(4:3),等比没做好,水平被额外压了 1.33 倍。目标手柄的检测分从 0.68 直接掉到 0.004——模型看着一个被压扁的世界,当然不认识。修法是分析尺寸永远按显示比例等比推算、只缩不放大。

还有个连 round 和截断都会咬人的细节:360 × 0.65 浮点算出来是 233.99998,截断得 233,而验证脚本是 round 得 234。差 1px,App 的结果就再也和离线验证对不上账。这种坑不踩一次,你永远觉得"差不多得了"。

旁注:跟物体和跟人的切换里,最容易被忽略的是"真机标定"。增益、旋转符号和 $MV 量纲的对应,文档里写了"待做",真机上一跑就知道 wz 转多少度算到位——这件事后面第七节还会回来咬人。


六、最险的翻车:正反馈 ​

架构对、模型准、预处理对,实车一测:目标在画面右边,车往左转——不是偶尔,是稳定地转离目标。

这修不了靠调参,是控制律的符号写反了,正反馈:

kotlin
// 修复前:目标在右(dxNorm > 0)→ wz 取正 → 但固件口径 wz>0 是左转!
clamp((dxNorm * cfg.yawGain * cfg.wzMax), ...)
// 修复后:显式负反馈
clamp(((-dxNorm) * cfg.yawGain * cfg.wzMax), ...)

根子是口径打架。App 里我直觉"wz 正 = 右转",可固件麦轮逆解的真实口径是 wz>0 = 左转——固件注释和 App 状态机注释两处都明写着"wz>0 = 左转",写控制律那一下凭直觉走了。

逮住它的过程比 bug 本身有意思。Agent 写了个离线回放脚本,拿真实录制的素材灌进"检测 + 跟踪 + 控制律"整条管线,统计每帧转向方向:

素材锁定帧越过死区的帧其中转向背离目标
手柄段 1(370 帧)136129106 帧
手柄段 2(284 帧)149149149 帧
真人(160 帧)3277 帧

凡越过死区的帧,100% 都在往反方向转。 再配一个一维闭环仿真交叉验证:

当前实现(正反馈):θ = 25° → 51° → 78° → …   ✗ 发散,目标跑出画面
修正实现(负反馈):θ = 25° → −1.2° → −1.2°   ✓ 收敛到对准

修完补了条断言:全视场任意位置,sign(wz) == −sign(dxNorm) 必须恒成立。把符号钉死,以后再有人改反,测试当场红。

这条留下的教训:符号这种"直觉级"的东西,必须以两处独立实现 + 真机口径为准,不凭手感。而离线回放是最便宜的实车——检测对不对、控制反没反,录制素材在电脑上整条管线跑一遍,一分钟现形,不用等真机撞墙。


七、调参:数据说话,不靠感觉 ​

功能通了之后是漫长的调参。这一阶段的主力是 Agent 搭的一套录像分析流水线:实车跟随时同步录下原始视频、每帧检测结果、每条真下发的指令、每个控制周期的决策——四样共用同一时钟,可逐帧对齐。于是"感觉跟得不紧"这种模糊抱怨,都能变成具体数字。三段分析,各扒出一层皮。

第一段:50ms 下界是抄来的。 固件规定转向脉冲最短 50ms,注释写"短于一个主循环周期无法准确执行"。用 git 追溯,这个 50 是拷贝传播:最早是超声波采样周期 SAMPLE_MS=50,后来写转向下界时被顺手抄了,再后来前进下界又抄了转向的。三个 50,只有超声波那个有独立依据;实际主循环最坏才 30ms。而真正的约束在观测侧:手机姿态真值只有 7.5Hz(133ms 一帧),50ms 的脉冲常常一帧都量不到——"短脉冲量不准"不是玄学,是尺子太粗。

第二段:车为什么拼命往后跑。 室内实测 21% 的时间车在倒车。根因不是哪个参数没调好,而是一个参数被迫干两份活:desiredGap 既要说"跟多远"又要说"多近算危险",两者夹出的静止带只有 11cm——人正常走动的框底抖动都比这大,于是反复穿越阈值、反复倒车。解法是标准的不对称滞回:前进门(1m 追)和后退门(39cm 才倒)分开,中间约 90cm 全是"只停不退"的维持带。改完用双阈值扫描预演:室内后退占比从 21% 压到 16%(剩下的都是人真贴脸,该退)、室外几乎不受影响。

第三段:偏角为什么总是大。 录像归因发现 |θ| 超过 15° 占了 63% 的时长,直方图近均匀——车不是在死区附近微调,而是目标常被甩到画面边缘。把数字拼起来:一拍控制周期约 300ms,但只有 50ms 真在转(占空 27%);车角速度上限约 100°/s;人在 1m 内横着走,方位角速度能到 50–130°/s——需求是供给的 2–5 倍,再叠上 0.5–1.7s 的环路反应延迟,±44° 的瞬态偏角就是这个量级的必然结果。而同一指令的实测单拍转角散布约 6 倍(p50=2.3° / p90=14.3°):控制器按"50ms=5°"发,实际转了多少它毫不知情。

第三段直接催生了下一轮改造清单:大偏角时别再脉冲-等待、直接连续满舵;settle 时间按档位区分;换 100–200Hz 的陀螺仪当真值源。调参的终点往往不是找到那个"对的数",而是发现参数模型缺的那一维。


八、验证:测试也要"打假" ​

这套东西的验证分三层,一层比一层较真。

第一层,单元测试。 全量 64 个类、1233 个用例全绿。关键在断言怎么写——符号、增益这些要写死数值(wz == −288),不能"用同一个常量算出来再比一遍"。后者只验算了一遍算术,什么都没钉住。

第二层,变异测试。 测试全过不等于测试有效。做法是脚本自动把源码改坏——符号翻转、增益加倍、边界 <= 写成 <、默认值偷偷放宽——每改一处跑一遍,必须看到测试转红,才算这条断言真承重。跟随控制律那轮 9 个变异体 9 个被抓住。最值钱的一个在后退门那轮:把默认值从 39cm 无声改宽到 88cm——构造期校验根本拦不住(新值还在合法区间),只有钉死数值的断言逮住了它。"无效输入不做静默兜底"这条纪律,是被变异测试逼着才真正在线的。

第三层,实车三件套。 真车跑起来,视频、识别日志、指令清单同生命周期录制(开始四样同时起、停时同时封口),时间轴对齐。这样"录像里框跳了一下"能立刻翻到同一时刻的检测行和下发指令,分辨是检测抖了、被节流器去重了、还是被 AEB 砍了。只录视频等于只有现象、没有数据。

三层合起来,其实只回答一个问题:你说"修好了"的时候,凭什么。


九、翻车清单 ​

症状根因对策
目标在右,车往左转,稳定反向控制律符号写反:App 直觉 wz>0=右转,固件口径 wz>0=左转负反馈 + 六点扫描断言钉死
检出率 88% 上不去letterbox 照抄 demo 左上对齐,训练口径是居中改居中,99.8%
手柄分 0.68 → 0.0044:3 画布套 16:9 源,水平各向异性压缩 1.33×分析尺寸按显示比例等比、只缩不放
App 与离线分数对不上360×0.65 截断 233 vs round 234,差 1px全链路统一 round
nano 一上真机就不行int8 量化毁了表征力(99.8%→27%)换 fp16/fp32,先对再快
转向最短 50ms 没道理拷贝传播:超声波采样周期被抄成转向下界git 追溯;真下界在观测侧(7.5Hz 量不了 50ms)
室内 21% 时间在倒车单一 desiredGap 干两份活,静止带仅 11cm前进门/后退门分离,不对称滞回
人横走车追不上、偏角大占空 27% + 延迟 0.5–1.7s,角速度供需差 2–5 倍粗档连续律 + 换陀螺仪(进行中)
测试全绿不敢信断言用"同一常量算边界",什么都没钉住关键值写死数值 + 变异测试确认转红

代码及时提交到git,别不小心被干没了,token也是很心疼的,而且ai没法一次性实现你不断调试后达成的效果!!!

十、总结 ​

离呼叫小车,自动导航过来,目标还有点远!!

视频链接: https://mp.weixin.qq.com/s/HBxYsdjLBEvyTB9KROM9eA