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



目录
- 一、任务:让小车跟着人走
- 二、选路线:UWB,我最后放弃了
- 三、选模型:许可是硬约束,不是坑
- 四、架构:模型只准建议,不准开车
- 五、眼睛:像素差 1px,检出率差十几倍
- 六、最险的翻车:正反馈
- 七、调参:数据说话,不靠感觉
- 八、验证:测试也要"打假"
- 九、翻车清单
- 十、总结
一、任务:让小车跟着人走
硬件没变,只是手机从"架在对面看机械臂"挪到了车上:
| 部件 | 干什么用 |
|---|---|
| 小车 | 麦克纳姆轮,四个轮子由总线舵机驱动,文本指令控制 |
| 控制板 | ESP32,MicroPython 固件,解析指令、驱动轮子、读超声波 |
| 手机 | 安卓真机,固定在车上,摄像头当"眼睛",跑 CheapCam |
| 链路 | 手机蓝牙透传 → ESP32 串口 → 轮子 |
"跟着人走"听着就一句话,动手才知道它其实问了三件事:人在哪(目标得是人,不能让人身上挂个信标)、多远(太远追上、太近倒退,不能一头撞腿上)、该不该停(任何情况人挡前面必须停,宁可错停)。
第三件最要命,第四节再说怎么兜。前两件,得先选条技术路线。
二、选路线:UWB,我最后放弃了
我认真评估过 UWB:车上装基站,人揣个标签,基站串口直接吐"角度 + 距离",20Hz 刷新,角度 ±3°、距离 30cm。台架实测一次通过,连"负角度怎么编码"这种最容易踩错的地方都拿真实数据钉死了——照抄成无符号就会左右反转,而这是"车要朝人开过去"的模式,反转就是越开越远。
纸面上它比视觉强一个量级:不受光照影响、不占算力、直接给距离(视觉只能靠检测框高度猜)。但我最后把它挂起了,两个原因:
1、我根据ai提供的信息,认为mate80手机已经携带了uwb信标,实际上它根本没法跟我购买的基站互通。
2、另外一种就是手拿着信标,但是供电是个问题,买回来的是2个芯片,基站和信标需要单独供电。 esp32芯片是5V电压给小车上的超声波供电,这个uwb基站要求3v供电,这~~~~~ 另外信标拿手上也没地方供电。
没搞过硬件还是经验不足啊


三、对象识别的模型选择
需要考虑一下,yolo是AGPL协议。
| 模型 | person(160 帧) | 手柄(654 帧) | 真机推理 |
|---|---|---|---|
| YOLOX-Tiny@416 fp32 | 100% / 1 次切换 | 99.8% | 222ms |
| YOLOX-nano@416 int8 | 63% / 21 次 ✗ | 3–27% ✗ | 28ms |
| YOLO11n@640 | 100% | 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 帧) | 136 | 129 | 106 帧 |
| 手柄段 2(284 帧) | 149 | 149 | 149 帧 |
| 真人(160 帧) | 32 | 7 | 7 帧 |
凡越过死区的帧,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.004 | 4: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没法一次性实现你不断调试后达成的效果!!!
十、总结
离呼叫小车,自动导航过来,目标还有点远!!