Expert Data Collection: How the Coach Car Drives, Part 1
纯追踪是一类几何路径跟踪算法:不跟误差信号打交道,而是在前方路线上选一个前瞻点(lookahead point),把车”用线牵”过去。每一步只做一件事——算一条从当前位置出发、经过前瞻点的圆弧,然后按这条弧打方向。
设车与前瞻点的距离为 (前瞻距离),车身朝向与车指向前瞻点连线的夹角为 。纯追踪要求圆弧同时切于车身当前朝向、并穿过前瞻点。由弦切角关系,这条弧的半径为:
对应的曲率 :
这就是纯追踪的全部控制律:曲率正比于前瞻点偏角的正弦。前瞻点偏得越厉害( 大),转得越狠;前瞻点越正(),方向越回直。
把 换个读法:这是对横向偏差(用 表达)的比例控制,增益是 。 越小增益越大——响应快但易超调振荡; 越大增益越小——稳但迟钝,弯道跟不上。这个参数的全部权衡由此而来(前瞻距离怎么选,见 B07 对应的深读)。
def _advance(self, x, y):
# 推进最近点索引(单调,防回跳)
while self.idx < len(self.wps) - 1:
wx, wy, _ = self.wps[self.idx]
nx, ny, _ = self.wps[self.idx + 1]
# 下一个路点比当前路点更近? 就前进一格
if ((x - nx) ** 2 + (y - ny) ** 2) < ((x - wx) ** 2 + (y - wy) ** 2):
self.idx += 1
else:
break
(tools/t31_collect_paired.py 第 57–65 行,PurePursuitFollower._advance。)
如果每帧全局搜索”离车最近的路点”,在掉头和 S 弯上会出事故:路线在这些地方自我折叠,几何上的最近点可能突然跳到车身后方的另一段路线上。索引一回跳,前瞻点跟着回跳,车就会原地画圈甚至倒着开。
while 循环只在”下一点比当前点更近”时前进一格,索引单调不减,从机制上消灭了回跳:折叠段上哪怕几何最近点确实在身后,控制器也只认”当前点及以后”的世界。
因为索引单调且只沿路线前进,它同时免费充当了路线完成度指标——主循环里 idx >= len(wps) - 3 就判定本路线采完、销毁换线(t42_collect_full.py)。不需要另写一套”是否到终点”的判定。
单帧最坏要连跳多格,但整个路线生命周期里索引从 0 单调爬到 ,总推进次数不超过 。摊到每帧就是常数级——20Hz 控制循环里这条路每帧只花几次距离比较,比每帧全表 扫描最近点便宜且行为更正确(全局扫描反而会引入上面的回跳 bug)。这是一个”快”与”对”同时成立的罕见例子:单调性既是性能优化,也是正确性约束。
sqrt,数值结果完全等价。while 而不是 if:车辆瞬移式跳动(如 spawn 后的第一帧、物理引擎抖动)时一帧可能要连跳多格才能重新咬住路线。# 前瞻点: 距 ego >= lookahead 的下一个 wp
tgt = self.wps[min(self.idx + 1, len(self.wps) - 1)]
dist = math.hypot(tgt[0] - x, tgt[1] - y)
j = self.idx + 1
while j < len(self.wps) - 1 and dist < self.lookahead:
tgt = self.wps[j + 1] # 距离不够, 继续往后找
dist = math.hypot(tgt[0] - x, tgt[1] - y)
j += 1
(tools/t31_collect_paired.py 第 71–78 行。)
从最近点索引出发沿路线向后走,取第一个离车超过前瞻距离 的路点作为前瞻点。搜索起点复用了最近点推进的结果,所以每帧只在局部窗口内扫几个点,同样是常数级开销。
纯追踪的曲率律 里, 扮演比例增益的倒数。两个方向都有代价:
| 行为 | 代价 | |
|---|---|---|
| 太小 | 紧贴路线,横向误差小 | 对路点噪声敏感,方向盘高频抖动,轨迹不平滑 |
| 太大 | 平滑稳定 | 弯道切内角(corner cutting):弧还没走到弯心就开始回正,轨迹内切 |
切内角不是小瑕疵:它直接决定教练车会不会刮上内弯的墙角。本项目的两组实测取值正是沿这个权衡调的——
代价在数据质检时兑现:Town10 紧凑路口处前瞻点常越过弯心,轨迹内切刮蹭静态物,成为 73% 路线带重碰撞的几何根因(ep19p2 的 B14 深读展开)。
转向公式里的分母用的是 max(self.lookahead, dist)(t31 第 84 行):接近路线终点时搜不到足够远的点,真实距离 ,此时仍按 归一,避免分母变小导致末端方向盘剧烈放大。
前瞻点的坐标是世界系(world frame)的,而”偏右 45 度”是车体系(ego/body frame)的说法。求夹角 之前,必须先把”车指向前瞻点”的向量从世界系旋进车体系。
设车在世界系的位置为 ,朝向角(偏航,yaw)为 ,前瞻点世界坐标减车位置得到世界系向量:
车体系 = 世界系绕 轴反向旋转 ,即乘旋转矩阵 :
是向量的纵向分量(车头方向), 是横向分量(车左方向)。夹角即:
前瞻点正在前方时 , 无需修正;偏右 45° 时(CARLA/UE 左手系 向右) 量级,符号随坐标系约定。
yaw = math.radians(transform.rotation.yaw)
lx, ly = tgt[0] - x, tgt[1] - y
alpha = math.atan2(
-math.sin(yaw) * lx + math.cos(yaw) * ly, # 横向分量 l_y'
math.cos(yaw) * lx + math.sin(yaw) * ly) # 纵向分量 l_x'
(tools/t31_collect_paired.py 第 79–83 行。)两个参数正是旋转矩阵第二行、第一行与向量的点积——“这两行展开就是旋转矩阵乘向量”,字面意义成立。
atan2 而不是 atan。atan(l_y'/l_x') 分不出象限:前瞻点在正后方与正前方算出同一个值,车会在倒库场景原地打转。atan2 吃两个独立参数,值域 ,前后左右分得清。steer = math.atan2(2.0 * 2.7 * math.sin(alpha),
max(self.lookahead, dist)) / (math.pi / 2.0)
(tools/t31_collect_paired.py 第 84 行。)
三个符号: m 是轴距(wheelbase,前后轴间距); 是前瞻距离; 是前瞻点在车体系下的方位角(求法见 B08 深读)。
第一步是纯追踪的圆弧(B04 深读):过当前位置、切于车头方向、穿过前瞻点的弧,半径
第二步是阿克曼转向几何(Ackermann steering geometry)的自行车模型近似:前轮转角 、轴距 与转弯半径 满足
代入 即得 。轴距出现在分子里是物理必然:同样的前轮转角下,轴距越长转弯半径越大(车越”钝”),所以要达到同一个 ,长轴距车需要更大的 。
CARLA 的 VehicleControl.steer 是归一化量,取值 ,对应物理最大转角约 量级。算出的弧度制 必须除以 才能喂给 API——这是单位换算,不是控制律的一部分。漏掉这一步,任何非零偏差都会打出满舵。
atan2(分子, 分母) 而不是 atan(分子/分母):分母被 max(lookahead, dist) 保证为正,两者等价,但 atan2 写法对极端输入更稳。ctl.steer 还会被 clamp 到 ,公式本身不负责限幅。err = self.target_speed - speed # 速度误差
self.i_err = max(-5.0, min(5.0, self.i_err + err * 0.05)) # 积分项, 限幅±5
d_err = (err - self.last_err) / 0.05 # 微分项, dt=0.05s
self.last_err = err
acc = self.kp * err + self.ki * self.i_err + self.kd * d_err
(tools/t31_collect_paired.py 第 85–89 行。)
连续形式 在 20Hz 定步长( s,仿真 fixed_delta_seconds)下离散化:
err * 0.05 与 (err - last_err) / 0.05 里的 0.05 就是定步长 ——同步模式下它是不变量,硬编码安全;异步模式下这么写会引入随帧率漂移的增益错误。
积分项被钳制在 :长时间大误差(起步、避障让行后恢复)下积分会无限累积,等误差反号时控制器还背着巨额”存量”,造成严重的反向过冲——这叫积分饱和(integral windup)。钳制是最简单的抗饱和方案。本项目 ,积分项实际不生效,限幅属于防御性保留。
积分项的价值是消除持续存在的未知扰动(风阻、坡度、载重)造成的稳态误差。仿真里执行器严格服从命令、无模型失配,PD 已经能把速度压到目标值附近;此时挂上积分反而引入相位滞后——误差消失后积分项还在输出,速度在目标值附近来回摆动。教练车求的是”演示平滑可模仿”,振荡的速度曲线会直接变成噪声标签(油门/刹车值就是训练标签),所以宁缺毋滥。
增益实测取值:t31 冒烟用 ;t42 正式采数为配合提速把 提到 1.5(t42_collect_full.py 第 152–153 行)。
col = world.spawn_actor(bpl.find("sensor.other.collision"), ...) # 碰撞: 质检真值
cam = world.spawn_actor(bpl.find("sensor.camera.rgb"), ...) # RGB 相机: 模型输入
gnss = world.spawn_actor(bpl.find("sensor.other.gnss"), ...) # GNSS: 全局定位
imu = world.spawn_actor(bpl.find("sensor.other.imu"), ...) # IMU: 罗盘+加速度
(tools/t42_collect_full.py 第 126–150 行。)
碰撞传感器不喂模型,它是质检的耳朵(impulse 与碰撞对象,ep19p2 用);GNSS/IMU 提供 measurements 里的 gps/compass/accel 字段,与评测链对齐;相机是唯一的模型输入通道。
cam_bp.set_attribute("image_size_x", "1024")
cam_bp.set_attribute("image_size_y", "512")
cam_bp.set_attribute("fov", "110")
cam_bp.set_attribute("exposure_compensation", "0.0") # 锁死自动曝光
# 位姿: 车顶偏后 1.5m, 高 2.0m
carla.Transform(carla.Location(x=-1.5, y=0.0, z=2.0), carla.Rotation())
这套参数逐项复刻 SimLingo 的训练相机(config_simlingo_base.py,t31 第 34–36 行注释明确标注”勿改”)。原因是域适配实验的变量控制:
fov 110° 的广视角让路侧信息(建筑、栅栏、路口)大量入画——这既是 UE5 高保真渲染的研究对象,也意味着教练车刮蹭静态物时”案发现场”都在画面里,脏数据的毒性有直接的视觉对应。
reshape(h, w, 4)[:, :, :3] 取前三通道得 BGR,OpenCV 直接 imwrite 成 jpg,没有任何后处理——与官方数据”原图落盘”的口径一致。exposure_compensation 属性名在 UE4(0.9.15) 与 UE5(0.10) 两侧不同,t31 的做法是准备候选属性名列表逐个探测设置(EXPOSURE_ATTR_CANDIDATES),设不上就声明默认值——跨引擎兼容的实用模式。仿真跑在同步定步长 s(20Hz),但每 5 个 tick 才落盘一帧:
ap.add_argument("--save-every", type=int, default=5) # 4fps 官方口径
...
if tick_n % args.save_every == 0 and "img" in fh: # 每 5 tick 存一帧
(tools/t42_collect_full.py 第 58、199 行。)
fps,即每 0.25 s 仿真时间存一条样本(一帧 jpg + 一个 measurements.json.gz)。
SimLingo 官方训练数据(carla_garage 血统)的存储率就是 4fps。模仿学习里时间粒度是数据语义的一部分:相邻样本的动作间隔、车速与位移的比例关系都锚定在 0.25 s 步长上。自采数据若用别的帧率,等于在输入分布里又掺了一个与渲染无关的变量。
存储帧率直接乘在数据体积上。T4.2 放飞总量 549,852 帧、110GB(CP4 报告实测口径):
| 存储率 | 同驾驶时长体积 | 结论 |
|---|---|---|
| 20fps(全量落盘) | ~550GB(110GB × 5) | 超出单机数据预算 |
| 4fps | 110GB | 落在 WSL ext4 分区的预算内 |
单帧约 200KB(1024×512 jpg + 压缩 measurements),帧率翻 5 倍体积就翻 5 倍,没有压缩技巧能抹平这个线性关系。
SimLingo 训练配置 pred_len = 11——模型学习预测未来 11 个路点。在 4fps 下:
存储粒度直接定义了模型要学的未来有多长。帧率变了,同样的 pred_len 对应的物理时间窗就变了,与官方预训练的归纳偏置错位。
nohup conda run -n mysim-ue5 env PYTHONPATH=tools python3 tools/t42_collect_full.py \
> logs/t42-full.nohup 2>&1 &
nohup 让进程忽略 SIGHUP——SSH 会话断开时终端发给前台进程组的挂断信号。配合 & 脱离作业控制,采集进程的生存期与登录会话解耦。日志重定向到 nohup 文件,事后排查全靠它。
CARLA server 易崩(项目硬性规则:所有采数/评测脚本内置崩溃自动重启)。守护逻辑在 tools/server_watchdog.py,关键设计是探活必须子进程隔离:
recv 会永久挂死——set_timeout 和 SIGALRM 都打不断(信号 handler 不回解释器,AGENTS.md T3.0 实测坑);server_alive 探活放在子进程里跑,用 subprocess 的 timeout 兜底:探活卡死 = 杀子进程判失联,主进程不被拖死;T4.2 放飞中途真实兑现过一次:actor destroy 触发 abort 使整个采集进程退出,守护发现进程消失后自动重启,断点续采接着跑(M4 复盘时间线 09-03~04)。
rdir = os.path.join(args.out, f"route_{rc.route_id:05d}_s{seed}")
if os.path.isdir(os.path.join(rdir, "measurements")) and \
len(os.listdir(os.path.join(rdir, "measurements"))) > 50:
continue # 断点续采: 已有路线跳过
(t42 第 92–95 行。)
恢复粒度是整条路线目录,不是单帧:一条路线要么完整重采、要么整条跳过。原子单位选路线而不是帧,是因为路线级真值(results.json.gz 的碰撞/完成度打分)只在路线收官时一次性写盘——帧级续采会产生”有测量无真值”的半成品目录,下游过滤语义就乱了。代价是崩溃当条路线白跑,几十到两百帧的损失,可接受。
ht = hero.get_transform()
hyaw = math.radians(ht.rotation.yaw)
fx, fy = math.cos(hyaw), math.sin(hyaw) # 车头单位向量
for bv in bg.actors: # 只遍历背景车!
bt = bv.get_transform()
dx, dy = bt.location.x - ht.location.x, bt.location.y - ht.location.y
fwd = dx * fx + dy * fy # 纵向投影
lat = abs(dx * -fy + dy * fx) # 横向投影(取绝对值)
if 2.0 < fwd < 7.0 and lat < 2.5: # 扇形判据
tgt_spd = min(tgt_spd, max(0.0, fwd - 4.0))
break
(tools/t42_collect_full.py 第 176–190 行。)
把每个背景车的相对位置投影到车体系(与 B08 深读同一个旋转矩阵),落在”前方 2–7 m、横向 ±2.5 m”扇形内就让行:目标速度压到 fwd - 4,即离得越近开得越慢,4 m 处停车让行。投影用点积/叉积各一次,每帧 6 辆背景车,开销可忽略。
if tick_n * args.dt < 2.0:
tgt_spd = 0.0 # spawn 后前 2s 怠速
(t42 第 191–193 行。)
动机是数据质量而非安全:spawn 瞬间车身可能与静态物穿模/紧贴,一动就蹭;而碰撞传感器在如实记录一切,这种”非驾驶行为导致”的蹭碰会混进路线级碰撞真值,把无辜路线打上污染标记。怠速 2 秒让世界物理稳定下来,再起步。
避障循环遍历的是 bg.actors——背景车列表,且只有背景车。墙、栅栏、灯杆等静态物根本不在感知名单里,PurePursuit 又只管贴线,“贴线”与”撞墙”之间没有任何仲裁。这个偏科设计在采数时无感(让行逻辑对动态交通确实有效),直到质检才发现:99.6% 的路线有碰撞记录、73% 带重碰撞,碰撞对象里静态物占绝对多数(qc.json 与 CP4 报告实测)。
这是一类典型的测试对象错位:避障逻辑通过了它设计范围内的所有场景(动态让行),但它的缺陷在范围之外——而不在范围内的东西恰恰是测试时最容易忘记列进检查单的。
break 只处理第一辆命中的背景车:让行取最保守的速度即可,多车叠加不改变决策。fwd > 2.0 的下界防自触:紧贴车尾/并行的车不触发让行,否则教练车会被旁边车道的车”吓停”。