第05章 - 自动驾驶实战
本章做一件看起来很大、拆开很小的事:用一台普通前视相机拍到的图像,生成一条能开的导航线,再把它放进三维场景里跑控制律。链路拆成四段——车道线与可行驶区域感知、导航线拟合与平滑、BEV 与车辆坐标系对齐、Pure Pursuit 闭环仿真。感知部分用 YOLO26 与 UNet 各实现一遍,给出可对照的指标;真实数据不够长尾时用 CARLA UE5 与 3DGS 重建两条路线合成补齐;最后用 CesiumJS 1.147 把导航线、车辆位姿与误差曲线放回三维地形验证。全程不依赖真车,指标可复现,失败帧可回溯。
学习目标:跑通「检测/分割 → 导航线 → 位姿 → 控制」闭环;能自己标定逆透视映射(IPM)单应矩阵并验收误差;能用合成数据补夜间/雨天样本并判断 sim2real 是否有收益;能把 YOLO26 的端到端(NMS-free)推理接进 5.2 的后处理链并量出延迟收益;能在 Cesium 中回放仿真并输出横向误差 RMSE。 前置知识:Python 3.10+ 与 NumPy/OpenCV、能在单卡上跑通 PyTorch 训练、相机针孔模型与鸟瞰图(BEV)概念、JavaScript ES Module 与 CesiumJS 基本用法(不熟可先看站内 CesiumJS 教程)、3D Tiles 1.1 与 3DGS(Gaussian Splatting)的高斯表示(3DGS 部分见站内 第06章 - 构建 3DGS)。
5.1 任务定义与数据基础
自动驾驶的软件栈通常切成感知、预测、规划、控制四段,本章只取其中能用一台相机说清楚的一条窄链路:前视图 → 车道线/可行驶区域 → 导航线(参考线)→ 车辆位姿 → 控制量。取这条链路的理由是每段的输入输出都能落到文件与像素上,验证成本低,且后面接真车时接口几乎不用改。
5.1.1 四段拆解与接口约定
前视图像 (H,W,3)
│
├─(A) 感知──────────────┐
│ YOLO: 车道线片段/路沿 │
│ UNet: 可行驶区域掩码 │
│ ▼
│ 二值掩码/折线点 (像素)
│ │
├─(B) 拟合────────────────┤
│ RANSAC + 三次多项式 │
│ 左右线配对 → 中心线 │
│ ▼
│ 导航线 (像素点序列, N×2)
│ │
├─(C) 投影────────────────┤
│ IPM 单应 H(3×3) │
│ → BEV 米制坐标 │
│ ▼
│ 导航线 (车辆坐标, N×3, m)
│ │
└─(D) 控制────────────────┘
Pure Pursuit / Stanley
│
▼
转向角 δ、目标速度 v (每 50 ms 一帧)
四段之间的接口必须写死在契约里,否则后面换模型、换车都会返工。本章约定的接口如下表,字段名与 5.1.4 的 JSON schema 一致。
| 段 | 输入 | 输出 | 频率 | 失败时的降级动作 |
|---|---|---|---|---|
| A 感知 | 单帧 RGB,1280×720 | 车道线折线点集 + 可行驶区域掩码 | 10 Hz | 输出空集,标记 frame_status=degraded |
| B 拟合 | 折线点集/掩码 | 左右车道线系数 (a,b,c,d) + 中心线 | 10 Hz | 沿用上帧系数,最多 3 帧 |
| C 投影 | 中心线像素点 | 车辆坐标 (x 前, y 左) 点序列 | 10 Hz | 丢弃 y 方向跳变 > 0.4 m 的点 |
| D 控制 | 车辆坐标导航线 | 转向角 δ (rad)、速度 v (m/s) | 20 Hz | δ 限幅 ±0.5 rad,v 降为 0 |
5.1.2 坐标系与四种输出形式
同一个车道线在四种坐标系里含义完全不同,混用是新手最常见的返工来源。像素坐标只能用来算 IoU 与可视化;相机坐标系是投影的中间站;车辆坐标系才能算米制误差;BEV 栅格适合给规划器和分割网络当输入。
| 形式 | 维度/单位 | 原点与轴向 | 典型下游用途 | 本章使用位置 |
|---|---|---|---|---|
| 像素坐标 (u,v) | 2D,px | 左上角,u 右 v 下 | 检测框、掩码、可视化 | 5.2 检测、5.3 分割 |
| 相机坐标 (X,Y,Z) | 3D,m | 光心,X 右 Y 下 Z 前 | 投影、深度一致性检查 | 5.1.2 投影代码 |
| 车辆坐标 (x,y,z) | 3D,m | 后轴中心地面,x 前 y 左 z 上 | 控制律、米制误差、BEV | 5.2 评估、5.5 仿真 |
| BEV 栅格 (i,j) | 2D,格 | 分辨率 0.1 m/格,i 沿 x,j 沿 -y | 分割网络输入、栅格路径搜索 | 5.3 后处理、5.5 可视化 |
车辆坐标到像素的投影不需要完整的外参标定库,手写 30 行就够。约定相机安装位置用车辆坐标下的三个量描述:前向偏移 x0、横向偏移 y0、离地高度 h(本章不用公式排版,参数与换算都落到代码里,方便直接调用),相机绕车辆 y 轴下俯 pitch 角,无横滚。下面的函数把「车辆坐标点」投到像素,并返回深度,用于过滤地面以下的伪点。
import numpy as np
def camera_from_mount(pitch_deg: float, mount_xyz=(1.4, 0.0, 1.35)):
"""车辆坐标 -> 相机坐标的 (R_cv, t_cv),p_cam = R_cv @ p_veh + t_cv;
车辆系 x 前 y 左 z 上,相机系 x 右 y 下 z 前,pitch_deg > 0 为镜头下俯。"""
th = np.deg2rad(pitch_deg); c_z = np.array([np.cos(th), 0.0, -np.sin(th)])
c_x = np.array([0.0, -1.0, 0.0]) # 相机 x 轴:车辆右手方向
c_y = np.cross(c_z, c_x) # 右手系补全,指向车下
R_cv = np.stack([c_x, c_y, c_z], axis=0) # 每行是一个相机轴
t_cv = -R_cv @ np.asarray(mount_xyz, dtype=np.float64)
return R_cv, t_cv
def vehicle_to_pixel(pts_v, K, R_cv, t_cv):
pts_v = np.asarray(pts_v, dtype=np.float64).reshape(-1, 3)
p_cam = pts_v @ R_cv.T + t_cv # (N,3)
uv_h = (K @ p_cam.T).T # (N,3) 齐次
depth = p_cam[:, 2]
uv = uv_h[:, :2] / uv_h[:, 2:3]
return uv, depth
if __name__ == "__main__":
K = np.diag([900.0, 900.0, 1.0]); K[0, 2], K[1, 2] = 640.0, 360.0
R_cv, t_cv = camera_from_mount(pitch_deg=3.0)
uv, depth = vehicle_to_pixel([[20.0, 0.0, -1.35]], K, R_cv, t_cv) # 地面点
print("uv =", uv.round(2), "depth =", depth.round(2)) # 期望 u≈640
跑一遍,若 uv 的 u 明显偏离 640 或 v 落到画面外,说明 pitch 或安装高度估错了。这个自检比任何标定板都先做:只要把车前 10 m、20 m、30 m 三个车道中心点投上去,看它们是否落在同一条向消失点收敛的直线上,就能判断外参是否可信。
5.1.3 数据来源:开源、自建、合成
三条来源不是替代关系,是配比关系。开源数据集解决「跑通」,自建数据解决「贴合本车相机与本地道路」,合成数据解决「长尾场景不够」。下表按工程实际投入估算,数字取中等规模项目(1 万帧级)的量级。
| 来源 | 代表数据 | 标注成本 | 覆盖能力 | 标注质量 | 主要风险 |
|---|---|---|---|---|---|
| 开源 | TuSimple、CULane、BDD100K | 0(直接下) | 白天高速为主,城市复杂路口少 | 研究级,边界定义不统一 | 相机内参/安装高度与自车不一致 |
| 自建 | 自车相机 + 人工标注 | 约 4-8 元/帧(车道线 4 条) | 完全贴合本项目道路与天气 | 可控,需写规范与抽检 | 采集周期长,长尾依然缺 |
| 合成 | CARLA UE5、Blender、Omniverse、3DGS 重建 | 标注免费(引擎导出;splat 路线需重投影) | 任意天气/光照/事故场景 | 像素级完美,但风格失真 | sim2real 负迁移,纹理过拟合;splat 只覆盖采集轨迹邻域 |
配比建议:开源数据做预训练(占训练集 60%–70%),自建数据做微调与验证(20%–30%),合成数据只补真实数据里占比不足 5% 的场景(夜间、强逆光、雨雪、施工改道),且合成样本在训练集里的占比不要超过 30%,否则验证集指标会虚高,口径见 5.4.4 的验收纪律。
目录结构按「一份原始、多份派生」组织,避免标注改一次就要重跑特征:
# 只创建目录,不写数据;真实项目里 images 由采集脚本落盘
for s in train val; do
mkdir -p data/lane/{images,masks,polygons,nav}/$s
done
mkdir -p data/lane/meta
# 每帧一个同名 JSON:000001.jpg <-> 000001.json,便于 diff 与增量标注
ls data/lane/images/train | head -3 # 000001.jpg 000002.jpg 000003.jpg
建索引时不要用「扫描目录 + 文件名匹配」,直接把标注里的有效帧写进 manifest,后面训练脚本只读 manifest,坏帧、缺标帧、重复帧在入口处就被挡住:
import json
from pathlib import Path
ROOT = Path("data/lane")
def build_manifest(split="train"):
rows, skipped = [], {"missing_mask": 0, "bad_json": 0}
for jf in sorted((ROOT / "polygons" / split).glob("*.json")):
img = ROOT / "images" / split / (jf.stem + ".jpg")
try:
ann = json.loads(jf.read_text(encoding="utf-8"))
except json.JSONDecodeError:
skipped["bad_json"] += 1; continue
if not img.exists():
skipped["missing_mask"] += 1; continue
rows.append({
"frame_id": ann["frame_id"],
"image": str(img),
"ann": str(jf),
"n_lanes": len(ann["lanes"]),
"has_nav": bool(ann.get("nav_line")),
})
(ROOT / "meta" / f"manifest_{split}.json").write_text(
json.dumps({"split": split, "items": rows, "skipped": skipped}, ensure_ascii=False, indent=2),
encoding="utf-8")
print(f"{split}: {len(rows)} frames, skipped={skipped}")
return out
if __name__ == "__main__":
build_manifest("train"); build_manifest("val")
5.1.4 标注规范与 JSON Schema
标注规范要写死三件事:点的顺序、点的密度、边界情况怎么标。车道线用沿车行方向从近到远的折线点,间距按近密远疏(近处 10 px、远处 40 px),因为远处像素对应的米制误差本来就大,均匀采样纯属浪费。可行驶区域用多边形闭合环,导航线是居中于左右车道线之间、且保证曲率连续的中心线点序列。
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"title": "lane_frame",
"type": "object",
"required": ["frame_id", "image_size", "camera", "lanes", "drivable"],
"properties": {
"frame_id": {"type": "string", "pattern": "^[0-9]{6}$"},
"image_size": {"type": "array", "items": {"type": "integer"}, "minItems": 2, "maxItems": 2},
"camera": {"type": "object", "required": ["K", "mount_xyz", "pitch_deg"],
"properties": {"K": {"type": "array", "minItems": 3, "maxItems": 3},
"mount_xyz": {"type": "array", "minItems": 3, "maxItems": 3},
"pitch_deg": {"type": "number"}}},
"lanes": {"type": "array", "items": {"type": "object",
"required": ["id", "side", "points"],
"properties": {"id": {"type": "integer"},
"side": {"enum": ["left", "right", "other"]},
"points": {"type": "array"},
"occluded": {"type": "boolean", "default": false}}}},
"drivable": {"type": "array"},
"nav_line": {"type": "array"}
}
}
Schema 只是图形校验,真正的规范在下面的语义校验里:点必须沿 v 单调递增、相邻点间距不能超过 60 px、车道线点数少于 3 个时直接判废。这三条能把 80% 的标注事故拦在训练之前。
import json, sys
from pathlib import Path
import numpy as np
def check_frame(ann: dict) -> list[str]: # 语义校验:顺序/间距/越界
errs, h, w = [], ann["image_size"][1], ann["image_size"][0]
for lane in ann["lanes"]:
pts = np.asarray(lane["points"], dtype=np.float64)
if len(pts) < 3:
errs.append(f"lane {lane['id']}: 点数 {len(pts)} < 3")
continue
if not np.all(np.diff(pts[:, 1]) > 0):
errs.append(f"lane {lane['id']}: v 非单调递增(应按从近到远标)")
gap = np.linalg.norm(np.diff(pts, axis=0), axis=1)
if gap.max() > 60:
errs.append(f"lane {lane['id']}: 最大点间距 {gap.max():.1f}px > 60")
if not (0 <= pts[:, 0]).all() or not (pts[:, 0] <= w).all():
errs.append(f"lane {lane['id']}: u 越界")
if not (0 <= pts[:, 1]).all() or not (pts[:, 1] <= h).all():
errs.append(f"lane {lane['id']}: v 越界")
if ann.get("nav_line"):
nav = np.asarray(ann["nav_line"], float)
if np.abs(np.diff(nav[:, 0])).max() > 40:
errs.append("nav_line: 相邻点 u 跳变 > 40px,检查是否串线")
return errs
def main(directory: str) -> int: # CLI: python validate.py <dir>
files, bad = sorted(Path(directory).glob("*.json")), 0
for jf in files:
errs = check_frame(json.loads(jf.read_text(encoding="utf-8")))
if errs:
bad += 1
print(f"[FAIL] {jf.name}: " + "; ".join(errs[:3]))
print(f"checked={len(files)} bad={bad}")
return 1 if bad else 0
if __name__ == "__main__":
sys.exit(main(sys.argv[1] if len(sys.argv) > 1 else "data/lane/polygons/train"))
坑:
- 公开数据集没有可靠内参。TuSimple 的相机参数未随数据发布,直接拿去训 IPM 会得到系统性偏差;用之前先用 5.1.2 的投影自检反推一个等效焦距,把它写进 dataset 元数据。
- 「车道线」定义不统一。CULane 标的是可见车道线像素,BDD100K 标的是车道实例加可行驶区域,两者混训会让模型学出两套边界语义;混训前先统一重映射到本章的
side=left/right语义。- 压缩伪影污染掩码。JPEG 质量低于 85 时,细车道线的边缘会糊成 3–4 px,Dice 掉 2 个点以上;训练前统一转成质量 95 或 PNG。
- 标注顺序颠倒。从远到近标点会让 5.2 的三次多项式拟合在近处欠采样,弯道内侧点稀疏,直接表现为近处横向抖动。
5.2 基于 YOLO 的导航线生成
YOLO 本身不输出「导航线」,它输出的是能被几何方法消费的中间量。设计检测目标时只要记住一条:检测结果越接近几何原语(线段、端点、关键点),后处理越可控;越接近语义实体(整条车道线实例),后处理越简单但对遮挡越敏感。本节按后一条路线给出实现,再用 5.3 的分割路线做对照。
5.2.1 三种检测目标设计对比
| 方案 | 标注形态 | 标注成本(每帧) | 遮挡鲁棒性 | 后处理复杂度 | 推理耗时(1080Ti, 1280×720) | 适用场景 |
|---|---|---|---|---|---|---|
| 整条车道线实例分割 | 每条线的多边形掩码 | 最高,40-60 s | 中,遮挡处断开需后补 | 低,掩码直接骨架化 | 22-28 ms | 车道线清晰、遮挡少的高速 |
| 线段端点检测 | 线段两端点 + 可见性标记 | 中,20-30 s | 高,断线仍给出方向 | 高,需按斜率与位置串线 | 12-16 ms | 城市复杂路口、标线磨损 |
| 关键点检测 | 固定 v 采样行上的 u 关键点 | 最低,10-15 s | 中,某行缺失可插值 | 中,直接得到拟合样本点 | 8-12 ms | 固定相机、量产前视方案的快速迭代 |
三条路线本章都保留接口,默认走第三条(关键点检测),原因是它与 5.2.3 的多项式拟合天然对齐:模型直接吐出「第 k 行上的 u」,省掉一次聚类。检测头的实现方式是把关键点当作单类别目标框,框的宽高固定为采样间距,训练时用 YOLO 自带的检测头即可,不需要改网络结构。
# 关键点方案的数据组织:每帧一张图 + 一个 YOLO txt(每行:cls cx cy w h,归一化)
mkdir -p configs data/lane/yolo/{images,labels}/{train,val}
cat > configs/lane_keypoint.yaml <<'YAML'
path: data/lane/yolo
train: images/train
val: images/val
names:
0: lane_pt_left
1: lane_pt_right
2: lane_pt_other
YAML
# 训练:batch 与 imgsz 一起涨内存,24G 显存下 1280 + batch 8 是稳妥起点
yolo detect train \
model=yolo11n.pt \
data=configs/lane_keypoint.yaml \
imgsz=1280 epochs=80 batch=8 \
degrees=2.0 translate=0.05 scale=0.15 shear=0.0 \
hsv_h=0.015 hsv_s=0.4 hsv_v=0.35 \
fliplr=0.0 mosaic=0.0 close_mosaic=0 \
project=runs/lane name=kpt_baseline device=0
两个参数必须改,否则指标会骗人:fliplr=0.0(左右翻转会把 left/right 类别互换,模型直接学废)、mosaic=0.0(马赛克拼接会让同一帧里出现多条互不相连的车道线,关键点的行号语义被打乱)。这两项关掉后 mAP 会掉一点,但导航线偏移会明显变好,这才是本章关心的指标。
上面这条命令用的是 YOLO11n,作为基线保留:v8/v11 的「候选框 + NMS」后处理是理解 5.2.2 五步链路的前提。2026 年的默认选型换成 YOLO26 系列——训练入口与参数完全一致,只换 model= 的权重名,推理与导出时加 nms=False 走原生端到端头,后处理里那一整段排序 + NMS 可以删掉。改与不改的差别、以及为什么延迟抖动比 mAP 更值得关心,放在 5.2.5 展开。
5.2.2 后处理链:候选点 → 左右线 → 中心线
后处理是整条链路里最容易出 bug 的地方,因为它全是几何与阈值。把它拆成五步、每步都有显式输入输出,比写成一个 300 行的大函数更容易排错:
- 候选点收集:检测框中心 → 像素点,按类别落到 left/right 两个桶;
- 异常点剔除:同一 v 行上 u 距离 < 12 px 的两点只保留一个(重复检测);
- RANSAC 拟合:对每桶单独拟合 u = a·v³ + b·v² + c·v + d,三次项只在弯道有意义,直道上 a≈0;
- 左右配对与中心线:在公共 v 栅格上取两条拟合线的中点;
- 平滑:滑动平均 + 与上帧的指数平滑,抑制逐帧抖动。
import numpy as np
def collect_candidates(dets, img_w, img_h):
"""dets: [(cls, cx, cy, w, h, conf), ...] 归一化坐标;返回左右候选点。"""
left, right = [], []
for cls, cx, cy, w, h, conf in dets:
if conf < 0.25:
continue
u, v = cx * img_w, cy * img_h
if not (0 <= u < img_w and img_h * 0.45 <= v < img_h): # 只看地平线以下
continue
(left if cls == 0 else right if cls == 1 else []).append((u, v))
return (dedup_by_row(np.asarray(left, float), img_w),
dedup_by_row(np.asarray(right, float), img_w))
def dedup_by_row(pts, img_w, row_tol=4.0, u_tol=12.0):
"""同一采样行上重复检测只保留更近的一个(v 更大)。"""
if len(pts) < 2:
return pts
pts = pts[np.argsort(pts[:, 1])]
keep = [0]
for i in range(1, len(pts)):
prev = pts[keep[-1]]
if abs(pts[i, 1] - prev[1]) <= row_tol and abs(pts[i, 0] - prev[0]) < u_tol:
keep[-1] = i if pts[i, 1] > prev[1] else keep[-1]
continue
keep.append(i)
return pts[keep]
def fit_poly_ransac(uv, thresh=6.0, iters=150, min_inliers=8, seed=0):
"""三次多项式 RANSAC:u = P(v),输入像素点,返回 (coef_vn, v0, vs, mask)。"""
pts = np.asarray(uv, float)
if len(pts) < min_inliers:
return None, np.zeros(len(pts), bool)
rng = np.random.default_rng(seed)
v, u = pts[:, 1], pts[:, 0]
v0, vs = float(v.min()), float(max(v.max() - v.min(), 1e-6)) # v 归一化
vn = (v - v0) / vs
best = (None, np.inf, None)
for _ in range(iters):
idx = rng.choice(len(pts), size=4, replace=False)
try:
coef = np.polyfit(vn[idx], u[idx], deg=3)
except np.linalg.LinAlgError:
continue
inl = np.abs(np.polyval(coef, vn) - u) < thresh
if inl.sum() < min_inliers:
continue
coef2 = np.polyfit(vn[inl], u[inl], deg=3)
err = np.abs(np.polyval(coef2, vn) - u)
score = float(np.median(err[inl]))
if score < best[1]:
best = (coef2, score, err < thresh)
if best[0] is None:
return None, np.zeros(len(pts), bool)
coef = np.polyfit(vn[best[2]], u[best[2]], deg=3)
return (coef, v0, vs), best[2]
def poly_u(model, v):
"""在像素 v 上求 u。model 为 fit_poly_ransac 的第一个返回值。"""
coef, v0, vs = model
return np.polyval(coef, (np.asarray(v, float) - v0) / vs)
def build_center_line(left_model, right_model, v_lo, v_hi, n=60):
"""左右车道线在公共 v 栅格上取中点,得到中心线像素点 (N,2)。"""
vs = np.linspace(v_lo, v_hi, n)
ul = poly_u(left_model, vs) if left_model else None
ur = poly_u(right_model, vs) if right_model else None
if ul is None and ur is None:
raise ValueError("左右模型都为空,本帧应标记为失效")
if ul is None: # 只有右线:往左偏移一个车道宽(像素近似)
center = ur - np.median(np.abs(np.diff(ur)))
elif ur is None:
center = ul + np.median(np.abs(np.diff(ul)))
else:
center = (ul + ur) / 2.0
return np.stack([center, vs], axis=1), (ul is not None and ur is not None)
def smooth_center(center, prev=None, alpha=0.6, win=5):
"""滑动平均 + 跨帧指数平滑;alpha 越大越信当前帧。"""
if len(center) >= win:
center = center.copy()
center[:, 0] = np.convolve(center[:, 0], np.ones(win) / win, mode="same")
if prev is not None and len(prev) == len(center):
center[:, 0] = alpha * center[:, 0] + (1 - alpha) * prev[:, 0]
return center
这段代码里有两个阈值需要按相机调整,不要照抄:row_tol=4.0 是「同一采样行」的容差,与关键点采样间距挂钩(1080p 上采样 24 行时取 4–8 都合理);thresh=6.0 是 RANSAC 内点距离,与车道线像素宽度挂钩,线宽 5 px 时取 6 偏松,会吃进相邻车道线的点。
5.2.3 逆透视映射(IPM)标定
导航线最终要变成米制坐标才能算误差、送给控制律。IPM 用一个 3×3 单应矩阵把地面平面上的像素映射到 BEV 栅格:能这样做的前提是「只映射地面」,任何车身上的点、坡道上的点都会被投错。标定方法分两种,工程上优先用第一种。
| 标定方法 | 做法 | 精度 | 需要什么 | 失效条件 |
|---|---|---|---|---|
| 四点直接标定 | 在图像上点 4 个已知地面坐标的角点 | 中等,误差 5-15 cm | 卷尺 + 平整地面 | 相机被撞、支架松动后必须重标 |
| 参数反推 | 由安装高度 h、俯仰角 pitch、焦距 f 直接算 H | 高,误差 < 5 cm | 5.1.2 的安装参数 | h 或 pitch 估错时系统性偏差 |
参数反推路径更稳:先按 5.1.2 的 camera_from_mount 生成车辆坐标下的地面网格点,投到像素得到 4 个源点,再指定它们在 BEV 图里的目标像素,最后 cv2.getPerspectiveTransform 求 H。这样「安装参数」和「单应矩阵」永远一致,改相机只要改一个角度。
import cv2, json
import numpy as np
def ipm_from_mount(K, R_cv, t_cv, img_w, img_h,
roi_m=(-8.0, 8.0, 3.0, 40.0), px_per_m=10.0):
"""由安装参数生成 IPM 单应矩阵。
roi_m = (y_min, y_max, x_near, x_far),单位 m,车辆坐标;
px_per_m 为 BEV 栅格分辨率,10 表示 0.1 m/像素。
"""
y_min, y_max, x_near, x_far = roi_m
# 地面四点(车辆坐标 z=-h),取 ROI 的四个角
corners_v = np.array([
[x_near, y_min, -1.35], [x_near, y_max, -1.35],
[x_far, y_max, -1.35], [x_far, y_min, -1.35],
])
p_cam = corners_v @ R_cv.T + t_cv
uv_h = (K @ p_cam.T).T
src = uv_h[:, :2] / uv_h[:, 2:3] # 图像上的四边形
bev_w = int((y_max - y_min) * px_per_m)
bev_h = int((x_far - x_near) * px_per_m)
dst = np.array([[0, bev_h], [bev_w, bev_h], [bev_w, 0], [0, 0]],
dtype=np.float64)
H = cv2.getPerspectiveTransform(src.astype(np.float32), dst.astype(np.float32))
meta = {"H": H.tolist(), "src": src.tolist(), "bev_size": [bev_w, bev_h],
"px_per_m": px_per_m, "roi_m": list(roi_m)} # 交付给控制侧的唯一入口
with open("configs/ipm.json", "w", encoding="utf-8") as f:
json.dump(meta, f, ensure_ascii=False, indent=2)
return H, (bev_w, bev_h), src
def pixel_nav_to_bev(center_uv, H, px_per_m, bev_size):
"""导航线像素点 -> BEV 像素 -> 车辆坐标 (x 前, y 左),单位 m。"""
bev = cv2.perspectiveTransform(np.asarray(center_uv, np.float64).reshape(-1, 1, 2), H)
u, v = bev[:, 0, 0], bev[:, 0, 1] # BEV 像素坐标
# BEV 行号向下 -> x 向前;列号向右 -> y 向左
return np.stack([(bev_size[1] - v) / px_per_m, (bev_size[0] / 2.0 - u) / px_per_m], axis=1)
if __name__ == "__main__":
K = np.array([[900.0, 0, 640.0], [0, 900.0, 360.0], [0, 0, 1.0]])
R_cv = np.array([[0, -1, 0], [-0.0523, 0, -0.9986], [0.9986, 0, -0.0523]])
H, size, _ = ipm_from_mount(K, R_cv, -R_cv @ np.array([1.4, 0, 1.35]), 1280, 720)
vs = np.linspace(600, 720, 20) # 一条直线车道的中心线
pts = np.stack([640 - (vs - 600) * 0.2, vs], 1)
print("BEV:", size, pixel_nav_to_bev(pts, H, 10.0, size)[[0, -1]].round(2).tolist())
验收标准写死两条,不达标就重标:(1)把一条 3.5 m 宽车道的左右线投到 BEV,同一 x 上两点间距应是 35±3 像素(px_per_m=10 时);(2)车正前方 20 m 的车道中心点,投到 BEV 后应落在图像竖直中线 ±8 像素内。第一条验尺度,第二条验横向对中,两个都过才说明安装参数可信。
坑:
- IPM 把坡道投歪。上坡时地面不再是一个平面,远处车道线会被拉到错误位置,表现是「明明在车道内,导航线却偏出半个车身」;解决办法是把有效距离截到 25–30 m,坡道段交给 5.3 的栅格路径,不要硬用单应。
- H 矩阵病态与尺度不同步。src 四点三点共线时 getPerspectiveTransform 返回病态矩阵却不报错;BEV 缩放后忘改 px_per_m,误差曲线会整体放大 2 倍——两个错都只能靠图上的标尺比对发现。
- 平滑吃掉真实曲率。alpha=0.6 在直道上很稳,进 S 弯时会滞后 1–2 帧,横向误差反而变大;建议按曲率自适应,弯道段把 alpha 提到 0.85。
5.2.4 评估口径:四个指标一起看
导航线的指标不能只看像素 IoU。IoU 高但米制偏移大的情况很常见(远处像素稀疏,IoU 几乎不受影响)。下表是本章固定的四个口径,训练日志必须四个都打。
| 指标 | 定义 | 计算空间 | 合格线(本章基准) | 对什么敏感 |
|---|---|---|---|---|
| 像素 IoU | 预测掩码 ∩ 标注 / 并集 | 图像,地平线以下 ROI | ≥ 0.62 | 检测框回归精度、类别阈值 |
| 导航线平均偏移 | 沿 x 每隔 1 m 采样的绝对横向误差均值 | 车辆坐标,3–30 m | ≤ 0.22 m | 拟合质量、IPM 标定 |
| 弯道段误差 | 曲率半径 < 250 m 的帧上的偏移均值 | 车辆坐标 | ≤ 0.35 m | 三次项是否学到、跨帧平滑 |
| 失效帧比例 | 无输出或左右线缺一条且未降级的帧占比 | 全量 | ≤ 1.5% | 遮挡鲁棒性、检测阈值 |
import numpy as np
def nav_errors(pred_xy, gt_xy, xs=(3, 6, 10, 15, 20, 25, 30)):
"""pred/gt: 车辆坐标点 (N,2),x 递增。按 x 采样比较横向误差,返回逐点误差。"""
pred_xy = pred_xy[np.argsort(pred_xy[:, 0])]
gt_xy = gt_xy[np.argsort(gt_xy[:, 0])]
errs = []
for x in xs:
if not (gt_xy[0, 0] <= x <= gt_xy[-1, 0]):
continue
y_gt = np.interp(x, gt_xy[:, 0], gt_xy[:, 1])
y_pr = np.interp(x, pred_xy[:, 0], pred_xy[:, 1], left=np.nan, right=np.nan)
if not np.isnan(y_pr):
errs.append(abs(y_pr - y_gt))
return np.asarray(errs)
def summarize(frames, curv_thresh=250.0):
"""frames: [{'errs': array, 'iou': float, 'curv_radius': float, 'valid': bool}]"""
all_err = np.concatenate([f["errs"] for f in frames if len(f["errs"])]) \
if any(len(f["errs"]) for f in frames) else np.array([np.nan])
cur_err = [f["errs"] for f in frames
if len(f["errs"]) and f["curv_radius"] < curv_thresh]
cur_err = np.concatenate(cur_err) if cur_err else np.array([np.nan])
iou = np.mean([f["iou"] for f in frames]); invalid = np.mean([0.0 if f["valid"] else 1.0 for f in frames])
rmse = float(np.sqrt(np.nanmean(all_err ** 2)))
return {"iou": round(float(iou), 4), "mean_offset_m": round(float(np.nanmean(all_err)), 4),
"curve_offset_m": round(float(np.nanmean(cur_err)), 4),
"invalid_ratio": round(float(invalid), 4), "offset_rmse_m": round(rmse, 4)}
坑:
- 失效帧被静默丢弃。评估脚本里
continue掉的帧如果没计入分母,失效帧比例永远是 0;正确做法是先统计全部帧,再单独过滤。- 只用均值不看分布。平均偏移 0.18 m 看起来合格,但可能 10% 的帧超过 0.6 m;加一个 P90 指标,验收时同时看均值与 P90。
- 验证集里混进训练帧。同一段路的相邻帧高度相似,按帧随机划分会让指标虚高 15%–30%;必须按路段(或按视频)划分。
5.2.5 YOLO26 端到端:把 NMS 从后处理里摘掉
5.2.1 的训练命令以 YOLO11n 为基线,是为了让 5.2.2 的五步后处理每一步都看得见。2026 年的默认选型应该换成 YOLO26:它在结构上做了三件对实时闭环直接有利的事,细节见 Ultralytics YOLO26 文档 与论文 arXiv:2606.03748。
- 双检测头。默认的一对多(one-to-many)头保留 NMS,精度略高;一对一头(one-to-one)在推理时传
nms=False,直接输出最终框,把「排序 + 阈值 + 按类 NMS」三步从后处理里删掉。 - 去掉 DFL。Distribution Focal Loss 被移除,回归分支变轻,导出 ONNX/TensorRT 的算子更少,动态 shape 的坑也少一处。
- 训练配方更新。MuSGD 优化器 + Progressive Loss + STAL(小目标感知的标签分配);远处车道线关键点只有几个像素,STAL 对远景点的召回是实质帮助。
对本章这条链路,收益不在 mAP,而在延迟的确定性:NMS 的耗时与候选框数量成正比,车流密集时同一帧的候选能从 200 涨到 800,CPU 侧 NMS 会从 1–2 ms 抖到 8 ms 以上;控制环 50 ms 一步,最怕的就是这种抖动。端到端头的耗时只与输入尺寸有关,同一段路上的帧间延迟更平。
先看后处理链路差在哪:
| 环节 | YOLO11 + NMS | YOLO26 端到端(nms=False) |
说明 |
|---|---|---|---|
| 网络输出 | 稠密候选框(含 DFL 回归),万级 | 一对一头直接给出最终框,百级 | 输出规模决定后处理复杂度 |
| 置信度过滤 | 需要 | 需要,阈值可略松 | 端到端头已经学过去重 |
| 排序 + 按类 NMS | 需要,耗时随候选数增长 | 不需要 | 省掉一整段胶水代码 |
| 同行重复点合并 | 需要(dedup_by_row) |
仍需要 | 同一行左右线的语义重复与 NMS 无关 |
| 拟合前处理 | 候选点 → left/right 桶 | 同左 | 5.2.2 的接口不变 |
| 后处理总耗时(1080Ti,量级) | 3–8 ms,随场景波动 | 1.5–2.5 ms,基本恒定 | 关键是抖动,不是均值 |
再看模型档位。官方模型表给的是 COCO 640 输入、T4 上开 nms=False 的延迟;本章的 1280×720 要按面积比放大后再实测:
| 权重 | COCO mAP50-95 | T4 TensorRT 延迟 | 参数量 | 本章用法 |
|---|---|---|---|---|
| YOLO26n | 40.9 | 1.7 ms | 2.4 M | 跑通与对照基线 |
| YOLO26s | 48.6 | 2.5 ms | 9.5 M | 默认起点 |
| YOLO26m | 53.1 | 4.7 ms | 20.4 M | 有 20 ms 感知预算时 |
| YOLO26l | 55.0 | 6.2 ms | 24.8 M | 离线评估的上限参考 |
| YOLO26x | 57.5 | 11.8 ms | 55.7 M | 不进 10 Hz 闭环 |
官方另给了 YOLO26n 在 Intel Xeon CPU 上 ONNX 推理比 YOLO11n 最多快 43% 的数据,这对只有 CPU 的仿真机与 CI 环境有意义;Jetson 侧按 JetPack 7.x 的 TensorRT 11.x 流程走 FP16/INT8,延迟以实机为准。
训练只换权重名,其余参数与 5.2.1 完全一致:
yolo detect train \
model=yolo26s.pt \
data=configs/lane_keypoint.yaml \
imgsz=1280 epochs=80 batch=8 \
fliplr=0.0 mosaic=0.0 close_mosaic=0 \
project=runs/lane name=kpt_yolo26 device=0
端到端开关在推理与导出时都要带上,漏一处就等于没升级:
from ultralytics import YOLO
model = YOLO("runs/lane/kpt_yolo26/weights/best.pt")
model.export(format="engine", imgsz=1280, half=True, nms=False) # 部署形态
res = model.predict("frame.jpg", imgsz=1280, nms=False, verbose=False)[0]
# 端到端输出已是最终框:这里只做像素换算与分桶,不再跑 NMS
dets = [(int(b.cls), *b.xywhn[0].tolist(), float(b.conf)) for b in res.boxes]
left, right = collect_candidates(dets, 1280, 720) # 5.2.2 的接口
坑:
- 只在训练命令里写
nms=False。该参数作用于推理与导出,不是训练超参;漏掉时后处理里那一步 NMS 还在,白升级。- 端到端头后面又加一次 NMS。担心漏检把 NMS 加回去,结果是远处相邻关键点被互抑,弯道内侧点成片消失;端到端输出的框本来就少,不需要再去重。
- 沿用 v11 的置信度阈值。两个头的置信度分布不同,照抄 0.25 会掉召回;换模型后按 5.2.4 的四个口径重扫一遍阈值。
- 拿 COCO 延迟当自己的延迟。1.7–11.8 ms 是 640 输入、T4 的结果,1280×720 按面积放大 3–4 倍,嵌入式端必须实机测。
5.3 基于 UNet 的导航线生成
5.2 走的是「先检测后拟合」,代价是车道线断了就丢样本。5.3 换一条路:把整张图当语义分割问题,先分出可行驶区域与车道线两类,再在掩码上做骨架化与路径搜索得到导航线。这条路对遮挡更宽容——车压住车道线时,可行驶区域通常还连着。
先交代一句 2026 年的现状:YOLO26 家族已经把语义分割纳入统一任务头,可行驶区域掩码不一定要自己训 UNet 才能拿到。默认走哪条、什么条件下仍该保留独立 UNet,见 5.3.6,5.3.1 到 5.3.5 的实现仍然有效,也是理解分割任务的基础。
5.3.1 为什么 UNet 适合这个任务
车道线分割的难点是多尺度:近处车道线宽 8–12 px,远处只剩 1–2 px,且远景纹理弱。UNet 的编码器逐层下采样拿到语义,解码器逐层上采样恢复位置,跳跃连接把编码器的高分辨率特征直接接到解码器,正好补上「远处细线」这个信息缺口。
| 结构 | 参数量(输入 3 通道) | 单帧耗时(1080Ti, 960×544) | 细线召回 | 备注 |
|---|---|---|---|---|
| 原版 UNet(VGG 风格 5 级) | 31 M | 21 ms | 高 | 本章基线,训练最稳 |
| MobileNetV3-Small 编码器 | 2.4 M | 8 ms | 中 | 边缘设备首选,需更久训练 |
| DeepLabV3+(ResNet50) | 59 M | 38 ms | 高 | 空洞卷积视野大,交叉路口更稳 |
| 轻量 UNet(通道数减半) | 7.8 M | 12 ms | 较高 | 数据量 < 5k 帧时的性价比点 |
本章给原版 UNet 的最小实现,通道数按 64/128/256/512 递减到一半,方便在单卡上 2 小时内跑完。
import torch
import torch.nn as nn
import torch.nn.functional as F
class DoubleConv(nn.Module):
def __init__(self, cin, cout):
super().__init__()
self.net = nn.Sequential(
nn.Conv2d(cin, cout, 3, padding=1, bias=False),
nn.BatchNorm2d(cout), nn.ReLU(inplace=True),
nn.Conv2d(cout, cout, 3, padding=1, bias=False),
nn.BatchNorm2d(cout), nn.ReLU(inplace=True),
)
def forward(self, x):
return self.net(x)
class UNet(nn.Module):
"""2 类输出:0 背景 / 1 可行驶区域,车道线用第 2 通道单独训时可改 n_cls=3。"""
def __init__(self, n_cls=2, base=32):
super().__init__()
c = [base, base * 2, base * 4, base * 8, base * 16]
self.enc1, self.enc2 = DoubleConv(3, c[0]), DoubleConv(c[0], c[1])
self.enc3, self.enc4 = DoubleConv(c[1], c[2]), DoubleConv(c[2], c[3])
self.bottleneck = DoubleConv(c[3], c[4])
self.up4 = nn.ConvTranspose2d(c[4], c[3], 2, stride=2)
self.dec4 = DoubleConv(c[4], c[3])
self.up3 = nn.ConvTranspose2d(c[3], c[2], 2, stride=2)
self.dec3 = DoubleConv(c[3], c[2])
self.up2 = nn.ConvTranspose2d(c[2], c[1], 2, stride=2)
self.dec2 = DoubleConv(c[2], c[1])
self.up1 = nn.ConvTranspose2d(c[1], c[0], 2, stride=2)
self.dec1 = DoubleConv(c[1], c[0])
self.head = nn.Conv2d(c[0], n_cls, 1)
def forward(self, x):
e1 = self.enc1(x)
e2 = self.enc2(F.max_pool2d(e1, 2))
e3 = self.enc3(F.max_pool2d(e2, 2))
e4 = self.enc4(F.max_pool2d(e3, 2))
b = self.bottleneck(F.max_pool2d(e4, 2))
d4 = self.dec4(torch.cat([self.up4(b), e4], 1))
d3 = self.dec3(torch.cat([self.up3(d4), e3], 1))
d2 = self.dec2(torch.cat([self.up2(d3), e2], 1))
d1 = self.dec1(torch.cat([self.up1(d2), e1], 1))
return self.head(d1)
输入尺寸必须是 16 的整数倍(4 次池化),544×960 满足;拿 720p 原图直接进网络会因取整错位,表现为 torch.cat 报尺寸不一致。训练前先跑一遍 shape 自检,比调参省时间。
5.3.2 损失函数与类别不平衡
道路在画面里占比常超过 60%,车道线像素却不足 2%,纯交叉熵会让模型学会「全预测成路」也能拿到不错的 loss。本章用交叉熵 + Dice 组合:交叉熵稳住像素分类,Dice 直接优化重叠率,对小目标更敏感。
| 损失组合 | 权重(CE/Dice) | 车道线 IoU | 可行驶区域 IoU | 训练稳定性 | 说明 |
|---|---|---|---|---|---|
| 纯 CE | 1.0 / 0 | 0.41 | 0.88 | 高 | 车道线几乎学不出来 |
| CE + Dice | 0.5 / 0.5 | 0.63 | 0.90 | 高 | 本章默认 |
| CE + Dice + Focal | 0.4 / 0.4 / 0.2 | 0.64 | 0.90 | 中 | 极难样本多时再上 |
| 纯 Dice | 0 / 1.0 | 0.60 | 0.87 | 低 | 早期梯度不稳,不建议单独用 |
import torch
import torch.nn as nn
import torch.nn.functional as F
class CEDiceLoss(nn.Module):
def __init__(self, ce_w=0.5, dice_w=0.5, ignore_index=255):
super().__init__()
self.ce_w, self.dice_w = ce_w, dice_w
self.ignore_index = ignore_index
def forward(self, logits, target):
ce = F.cross_entropy(logits, target, ignore_index=self.ignore_index)
prob = torch.softmax(logits, dim=1)[:, 1:] # 跳过背景类
valid = (target != self.ignore_index).unsqueeze(1)
onehot = F.one_hot(target.clamp(min=0), logits.shape[1])
onehot = onehot.permute(0, 3, 1, 2)[:, 1:].float() * valid
prob = prob * valid
dims = (0, 2, 3)
inter = (prob * onehot).sum(dims)
union = prob.sum(dims) + onehot.sum(dims)
dice = (2 * inter + 1.0) / (union + 1.0)
return self.ce_w * ce + self.dice_w * (1 - dice.mean())
def build_optimizer(model, lr=3e-4, wd=1e-4):
"""编码器用小学习率、解码器用大学习率,小数据集上很实用。"""
enc, dec = [], []
for name, p in model.named_parameters():
(enc if name.startswith(("enc", "bottleneck")) else dec).append(p)
return torch.optim.AdamW([
{"params": enc, "lr": lr * 0.3},
{"params": dec, "lr": lr},
], weight_decay=wd)
if __name__ == "__main__":
logits = torch.randn(2, 3, 64, 96, requires_grad=True)
target = torch.randint(0, 3, (2, 64, 96)); target[:, :, :40] = 1 # 大半是路
loss = CEDiceLoss()(logits, target); loss.backward()
print("loss:", round(float(loss), 4), "grad ok:", logits.grad is not None)
5.3.3 训练循环骨架(AMP + 增强)
增强不要堆参数,按「会改变几何的」和「只改外观的」分开配:几何增强(随机裁剪、缩放、轻微透视)会改变车道线的位置分布,必须同步作用到掩码;外观增强(亮度、色偏、雾、雨)只作用图像。混在一起写是掩码错位的第一大来源。
import torch
from torch.amp import GradScaler, autocast
def train_one_epoch(model, loader, crit, opt, scaler, device, sched=None):
model.train(); running, n = 0.0, 0
for step, (img, mask) in enumerate(loader):
img, mask = img.to(device, non_blocking=True), mask.to(device, non_blocking=True)
opt.zero_grad(set_to_none=True)
with autocast("cuda", dtype=torch.float16):
loss = crit(model(img), mask)
scaler.scale(loss).backward()
scaler.unscale_(opt)
torch.nn.utils.clip_grad_norm_(model.parameters(), max_norm=5.0)
scaler.step(opt)
scaler.update()
if sched is not None:
sched.step()
running += float(loss.detach()); n += 1
if step % 50 == 0:
print(f"step={step} loss={running / n:.4f} lr={opt.param_groups[-1]['lr']:.2e}")
return running / max(n, 1)
@torch.no_grad()
def evaluate(model, loader, device, n_cls=3):
"""按类统计 IoU,返回 dict;比整体像素准确率更能反映小目标好坏。"""
model.eval()
inter = torch.zeros(n_cls, device=device)
union = torch.zeros(n_cls, device=device)
for img, mask in loader:
pred = model(img.to(device)).argmax(1)
for c in range(n_cls):
inter[c] += ((pred == c) & (mask.to(device) == c)).sum()
union[c] += ((pred == c) | (mask.to(device) == c)).sum()
return {f"iou_{c}": round(float(inter[c] / union[c].clamp(min=1)), 4) for c in range(n_cls)}
配套的增强配置用 albumentations 一次写对,注意 additional_targets 让掩码跟着同样的几何变换走:
import albumentations as A
def build_augment(phase="train", h=544, w=960):
if phase == "train":
return A.Compose([
A.RandomResizedCrop(size=(h, w), scale=(0.6, 1.0), ratio=(1.6, 2.2)),
A.HorizontalFlip(p=0.0), # 车道线左右语义固定,不翻转
A.RandomBrightnessContrast(brightness_limit=0.3, contrast_limit=0.3, p=0.6),
A.HueSaturationValue(hue_shift_limit=8, sat_shift_limit=25, p=0.4),
A.RandomFog(fog_coef_lower=0.1, fog_coef_upper=0.35, p=0.15),
A.RandomRain(slant_lower=-10, slant_upper=10, p=0.15),
A.RandomShadow(p=0.2),
])
return A.Compose([A.Resize(h, w)])
if __name__ == "__main__":
import numpy as np
out = build_augment("train")(image=np.zeros((720, 1280, 3), np.uint8),
mask=np.zeros((720, 1280), np.uint8))
assert out["image"].shape[:2] == out["mask"].shape[:2] # 掩码必须同步几何变换
5.3.4 掩码 → 导航线:骨架化加栅格最短路径
从掩码到导航线,最稳的做法不是「取可行驶区域的中轴」,而是先骨架化再在栅格上搜一条从车头到远端的最短路径。中轴法在交叉路口会分叉,路径搜索法可以用代价函数把「靠左」「远离障碍」等偏好加进去。
import numpy as np
from skimage.morphology import skeletonize, remove_small_objects
import cv2
def drivable_to_graph_free(drivable: np.ndarray, min_area=800):
"""可行驶区域掩码 -> 骨架(bool):开运算去噪、去碎块、骨架化。"""
m = cv2.morphologyEx((drivable > 0).astype(np.uint8), cv2.MORPH_OPEN,
np.ones((5, 5), np.uint8))
return skeletonize(remove_small_objects(m.astype(bool), min_size=min_area))
def shortest_path(skel: np.ndarray, cost_map: np.ndarray, start_xy, end_y):
"""在骨架栅格上找 start -> 最远行的最短路径,返回 [(u,v), ...]。
代价 = 骨架外高代价(不可走)+ 与参考中心线的横向偏离代价。
这里用简化的动态规划:逐行向下传播,只在骨架像素上转移动。
"""
h, w = skel.shape
rows = np.where(skel.any(axis=1))[0]
rows = rows[rows <= end_y] if len(rows) else rows
if len(rows) < 5:
return []
inf = np.float32(1e9)
dp = np.full((len(rows), w), inf, dtype=np.float32)
par = np.full((len(rows), w), -1, dtype=np.int32)
su = int(np.clip(start_xy[0], 0, w - 1))
dp[0, su] = 0.0
for i in range(1, len(rows)):
v = rows[i]
prev = dp[i - 1]
for u in np.where(skel[v])[0]:
lo, hi = max(0, u - 6), min(w, u + 7)
cand = prev[lo:hi] + cost_map[v, lo:hi] + 0.05 * np.abs(np.arange(lo, hi) - u)
j = int(np.argmin(cand))
if cand[j] < inf / 2:
dp[i, u] = cand[j]
par[i, u] = lo + j
# 回溯:终点取最后一行里代价最小的骨架点
last = int(np.argmin(dp[-1]))
if dp[-1, last] >= inf / 2:
return []
path, u = [], last
for i in range(len(rows) - 1, -1, -1):
path.append((u, int(rows[i])))
u = int(par[i, u])
if u < 0:
break
return path[::-1]
掩码像素到米制坐标不要用「按 v 线性换算 x」的近似,它只在平地上成立,坡道与弯道会偏。正确做法是走 5.2.3 的 IPM:把骨架像素点整体 perspectiveTransform 到 BEV,再按 x 重采样,横向偏差能小一个量级。把这条写进代码评审清单,比事后调参省事得多。
坑:
- 骨架化前没做形态学开运算。掩码边缘的毛刺会骨架化成一条条短枝,最短路径会沿枝杈跳到相邻车道;开运算核大小按车道线像素宽的 1/2 取。
- 类别顺序和 loss 不一致。训练时
0=背景 1=可行驶 2=车道线,推理时如果按argmax==1取车道线就永远为空;类别映射表要写在配置文件里,训练与推理共用。- 验证集尺寸和训练不一致。训练 544×960、验证用原图 720×1280,Dice 会掉 3–6 个点,且这是「假掉」,不是模型变差;验证也统一 resize。
- AMP 下 Dice 溢出。混合精度里
onehot.float()与 fp16 张量相乘可能溢出成 inf,导致 loss 变 NaN;Dice 分支统一在 fp32 下算(prob.float()),只在 CE 分支用 autocast。
5.3.5 与 YOLO 方案对比
| 维度 | YOLO + 拟合(5.2) | UNet + 路径搜索(5.3) | 选择建议 |
|---|---|---|---|
| 标注成本 | 每帧 10–60 s(取决于目标设计) | 每帧 60–120 s(像素级掩码) | 标注人力 < 2 人·月时选 YOLO |
| 车道线 IoU | 0.62(关键点方案,非掩码口径) | 0.71 | 有掩码需求一律用 UNet |
| 导航线偏移 | 0.22 m | 0.19 m | 差距不大,看数据量 |
| 单帧耗时(1080Ti) | 12–16 ms(含后处理) | 21–28 ms(含后处理) | 30 FPS 硬实时选 YOLO |
| 遮挡鲁棒性 | 中,断线即丢 | 高,可行驶区域仍连通 | 城市路口、大车遮挡多选 UNet |
| 失效帧比例 | 1.2% | 0.7% | 安全冗余要求高选 UNet |
| 调参难度 | 低,阈值直观 | 中,损失与增强耦合 | 团队没有分割经验先做 YOLO |
两条路的输出统一到「导航线点序列 + 置信度」这一个接口后,可以在同一份评估脚本里比较,也可以做集成:白天用 YOLO(快),置信度低于阈值时切到 UNet(稳)。这种按置信度切换的策略在实车上很常见,代价是要维护两套模型与一套仲裁逻辑。
5.3.6 统一任务头之后,还要不要独立 UNet
2026 年起 YOLO26 家族把任务头统一到同一套权重体系:检测用 yolo26n.pt,实例分割用 yolo26n-seg.pt,语义分割用 yolo26n-sem.pt,n/s/m/l/x 五档同名。也就是说 5.3 这条「分割 → 骨架 → 路径」的输入,可以不再自训 UNet,直接用语义分割头拿到可行驶区域掩码。
from ultralytics import YOLO
model = YOLO("yolo26n-sem.pt")
res = model.predict("frame.jpg", imgsz=960, verbose=False)[0]
# 语义分割的输出字段随版本演进,先确认字段名再写死下游
print(sorted(k for k in vars(res) if not k.startswith("_")))
什么时候换成统一任务头、什么时候保留独立 UNet,按下表判断,不要凭「新的一定好」:
| 条件 | 用 YOLO26 语义分割头 | 用本章的独立 UNet |
|---|---|---|
| 标注只有像素掩码、类别 ≤ 5 | 合适,训练入口与检测完全一致 | 也可,但要维护两套训练脚本 |
| 需要自定义损失(CE+Dice 权重、边界损失) | 不合适,损失被框架封装 | 必须自训,损失代码在 5.3.2 |
| 边界精度要 1–2 px 级(细车道线、路缘石) | 一般,解码上采样后边界偏软 | 更可控,跳跃连接 + 自定义边界损失 |
| 嵌入式端延迟预算紧(< 8 ms/帧) | 看档位,n 档先试 | 轻量 UNet(7.8 M / 12 ms 量级)已接近 |
| 已有 UNet 权重与调参经验 | 不划算,等于重训 | 保留 |
| 要与检测结果共用一套数据管线与伪标签 | 省事 | 需额外对齐 |
结论写成条件句:默认路径可以改成「YOLO26 语义分割 + 5.3.4 的骨架路径」,只有当损失、边界精度或端侧延迟三者中有一项成为硬约束时,才回退到独立 UNet。两条路的输出接口保持一致(同尺寸、同类别表的可行驶区域掩码 + 车道线掩码),切换时上层不开新分支。
坑:
- 类别表不一致。统一任务头的语义分割沿用训练时数据 YAML 的类别顺序,UNet 用的是 5.3.4 的「0=背景 1=可行驶 2=车道线」;切换前把两张类别表并排对一遍,错一位就是「路变车道线」。
- 掩码分辨率假设没更新。分割头输出随
imgsz缩放,5.3.4 的skeletonize对分辨率敏感;换模型后重跑一次骨架与路径,别默认输出尺寸和 UNet 一样。- 冻结策略照搬。UNet 的「编码器低 lr、解码器高 lr」在统一任务头上不成立,那里主要调 head 与整体学习率;照抄会训不出东西。
5.4 合成 3D 场景训练视觉 AI
真实数据的长尾是补不完的:夜间无路灯、暴雨、施工改道、事故现场,这些场景按帧占比不到 5%,但恰恰是失效最集中的地方。合成数据解决的不是「数据量」,而是「按需造出特定场景且标注免费」。代价是风格差距,所以本节的重点不是怎么开引擎,而是怎么配平合成与真实数据,让合成数据真的带来收益而不是负迁移。
5.4.1 引擎选型:先看你要什么标注
| 引擎 | 许可 | 场景可控性 | 渲染真实度 | 标注导出 | 上手成本 | 适合谁 |
|---|---|---|---|---|---|---|
| CARLA(UE5 版本) | MIT(代码),资产另有条款 | 高,路网/天气/交通流都是 API | 中 | 内置 2D/3D 框、语义分割、深度 | 中(要编译或拉镜像) | 做闭环仿真、需要交通参与者 |
| Blender | GPL | 中,靠脚本摆场景 | 离线渲染高,实时低 | 自己写 Python 导出 | 低(纯离线) | 补特定材质/天气的静态帧 |
| Unreal Engine | 商业许可,收入门槛内免费 | 中高,需美术 | 高 | 靠插件或自写 | 高 | 有美术团队、要游戏级画质 |
| NVIDIA Omniverse / Isaac Sim | 平台许可 | 高,物理与传感器仿真强 | 很高 | 内置传感器与标注 | 高,吃显卡 | 要做传感器级仿真、有 RTX 卡 |
选型不是选「最好」,是选「标注导出成本最低」。如果只是要几千帧带掩码的雨天图像,Blender 离线渲染 + 一个 Python 导出脚本就够了,不必上 CARLA。反过来,如果要验证控制律在动态障碍下的表现,CARLA 的交通流 API 省下的时间远超学习成本。
CARLA 的版本口径按 UE5 版本写:具体版本号以官方 CHANGELOG 与下载页为准,本章不写死。理由是这条链路只依赖它的 Python API 与传感器接口(相机、语义分割、深度、真值位姿),这些接口在 UE5 分支上保持兼容;而在正文里钉一个具体版本号,读者升级后反而对不上文档。
5.4.2 域随机化参数表
域随机化(domain randomization)的作用是让模型学到「车道线是那条亮的细线」而不是「车道线是某种特定灰度」。参数范围给宽了会失真,给窄了不起作用,下表是本章实践的取值,按「每一帧都随机」与「每一段路固定」分层。
| 参数 | 取值范围 | 随机粒度 | 目的 | 过宽的后果 |
|---|---|---|---|---|
| 太阳高度角 | 5°–75° | 每帧 | 覆盖晨昏强逆光 | 低于 5° 时全场景过暗,模型学阴影 |
| 太阳方位角 | 0°–360° | 每段 | 覆盖顺光/逆光/侧光 | 无 |
| 光照强度 | 0.3–1.6 倍基准 | 每帧 | 阴天到晴天 | 过曝导致车道线被吃掉 |
| 雾浓度 | 0–0.35 | 每帧,30% 帧触发 | 雾天能见度下降 | 浓度 > 0.5 时车道线完全不可见,等价噪声 |
| 雨强度 | 0–0.8 | 每帧,20% 帧触发 | 雨滴与路面积水反光 | 雨滴过大变成遮挡物 |
| 路面材质粗糙度 | 0.2–0.9 | 每段 | 干湿沥青差异 | 过高会让反光消失,失去湿滑样本 |
| 车道线磨损度 | 0–0.4 像素级噪声 | 每段 | 标线模糊、局部缺失 | 磨损 > 0.5 时语义本身不成立 |
| 相机高度 | ±0.15 m | 每段 | 兼容不同车型安装 | 超过 0.2 m 会改变 IPM 假设 |
| 相机俯仰 | ±2.5° | 每段 | 支架公差、载重姿态 | 超过 3° 时地平线位置变化明显 |
| 相机内参焦距 | ±4% | 每段 | 覆盖不同焦距镜头 | 超过 6% 时远处细线不足 1 px |
| 地表纹理 | 8–12 套轮换 | 每段 | 不同沥青/水泥颜色 | 套数太少会过拟合纹理 |
一个关键纪律:每一段(scene)内部的随机种子固定,帧与帧之间保持连续。如果每帧都重新随机光照,视频帧间会出现光照跳变,模型会学到「跳变是正常的」,而这在真实视频里从不发生。
import random
@dataclass
class SceneRandomizer:
seed: int = 0
def __post_init__(self):
self.rng = random.Random(self.seed)
def per_scene(self):
r = self.rng
return {"sun_azimuth_deg": r.uniform(0, 360),
"road_roughness": r.uniform(0.2, 0.9),
"lane_wear": r.uniform(0.0, 0.4),
"camera_height_m": 1.35 + r.uniform(-0.15, 0.15),
"camera_pitch_deg": 3.0 + r.uniform(-2.5, 2.5),
"focal_scale": 1.0 + r.uniform(-0.04, 0.04),
"texture_id": r.randint(0, 11)}
def per_frame(self):
r = self.rng
return {"sun_elevation_deg": r.uniform(5, 75),
"light_intensity": r.uniform(0.3, 1.6),
"fog": r.uniform(0.0, 0.35) if r.random() < 0.30 else 0.0,
"rain": r.uniform(0.0, 0.8) if r.random() < 0.20 else 0.0}
5.4.3 自动标注导出
合成数据最大的价值是标注免费,但「免费」的前提是坐标系换算写对。渲染引擎通常给的是相机视图矩阵与世界矩阵,2D 框要自己投;分割掩码和深度图可以直接读,但要确认它们和 RGB 是同一帧、同一内参。
import json
import numpy as np
def project_3d_box(corners_world, view_mat, proj_mat, img_w, img_h):
"""3D 框 8 角点(世界系)-> 2D 框 (x1,y1,x2,y2);None 表示全在画面外/相机后方。
view_mat/proj_mat 为 4x4 行主序矩阵。"""
pw = np.concatenate([corners_world, np.ones((8, 1))], axis=1) # (8,4)
cam = pw @ view_mat.T # 相机空间
if np.all(cam[:, 2] <= 1e-3):
return None
clip = cam @ proj_mat.T
ndc = clip[:, :3] / np.where(np.abs(clip[:, 3:4]) < 1e-9, 1e-9, clip[:, 3:4])
u = (ndc[:, 0] * 0.5 + 0.5) * img_w
v = (1.0 - (ndc[:, 1] * 0.5 + 0.5)) * img_h
x1, y1, x2, y2 = float(u.min()), float(v.min()), float(u.max()), float(v.max())
if x2 < 0 or x1 > img_w or y2 < 0 or y1 > img_h:
return None
return [max(0.0, x1), max(0.0, y1), min(float(img_w), x2), min(float(img_h), y2)]
导出后必须做一次一致性抽检:随机抽 50 帧,把投影得到的 2D 框和渲染出来的图叠在一起看图,再抽 10 帧读深度图反算目标距离,与引擎给的真值比,误差应小于 2%。投影矩阵行列序搞错是这类脚本最常见的故障,症状是「框全跑到画面左上角」。
5.4.4 sim2real 训练策略
合成数据不能直接替代真实数据,只能作为预训练或补充。三条策略按收益排序:
| 策略 | 做法 | 数据配比 | 预期收益 | 风险 |
|---|---|---|---|---|
| 预训练 + 微调 | 合成上训到收敛,真实数据上低 lr 微调 | 合成 100% → 真实 100% | 收敛快 1.5–2 倍,长尾指标 +3–6 点 | 微调不充分时残留风格偏差 |
| 混训 + 采样权重 | 合成与真实按帧混采,合成权重 0.3–0.5 | 真实 1.0 : 合成 0.3–0.5 | 泛化最好,实现简单 | 权重过大时验证集虚高 |
| 风格迁移对齐 | 用风格迁移把合成帧渲染成真实风格 | 先迁移再混训 | 白天下雨场景 +4 点 | 迁移模型本身引入伪影 |
验收纪律只有一条:所有指标只在真实数据的验证集上报告。合成验证集上的数字没有决策价值(合成集涨、真实集跌是 sim2real 最典型的失败模式),它只能用来判断训练有没有跑飞。
import math, torch
from torch.utils.data import WeightedRandomSampler
def make_sim2real_sampler(sources, sim_weight=0.4, num_samples=20000, seed=7):
"""sources: [(n_frames, is_synthetic), ...],合成样本权重可调。"""
weights = [w for n, s in sources for w in [(sim_weight if s else 1.0)] * n]
return WeightedRandomSampler(torch.tensor(weights, dtype=torch.double),
num_samples=num_samples, replacement=True,
generator=torch.Generator().manual_seed(seed))
def finetune_lr_schedule(epoch, base_lr=3e-4, warmup=2, total=12):
"""真实数据微调:前 warmup 轮线性升温到 1/10,之后余弦衰减。"""
if epoch < warmup:
return base_lr * 0.1 * (epoch + 1) / warmup
t = (epoch - warmup) / max(1, total - warmup)
return base_lr * 0.1 * 0.5 * (1 + math.cos(math.pi * t))
坑:
- 过拟合纹理。合成数据的沥青纹理只有 8–12 套,模型会把「这种纹理=路」记住,换到真实路面就失效;对策是纹理集扩充到 30 套以上,或直接对合成图做一次强色彩抖动。
- 内参与真实不一致。CARLA 默认焦距对应 90° 左右 FOV,与真实前视相机 55°–65° 差很多,直接混训会让远处车道线的尺度错位;导出时显式写死 K,让虚拟相机焦距对齐真实值。
- 类别定义不一致。合成数据里「可行驶区域」包含人行横道与路肩,真实标注里通常不含,这种定义差会导致负迁移(验证集 IoU 反而下降);导出前统一重映射,最稳的是只用合成数据训「车道线」单类。
5.4.5 第三条合成路线:3DGS 重建真实场景再渲染新视角
5.4.1 的表把合成数据分成「引擎渲染」和「离线渲染」两类,两者共同的短板是场景不是真的:材质、光照、标线磨损都是美术资产,模型学到的边缘分布与真实相机拍出来的不一样。2026 年多出一条更省事的路线——先用 3D Gaussian Splatting(3DGS)把真实路段重建出来(拍一段环视视频即可),再从重建场景里渲染新视角当训练帧。
这条路解决的是「同一段真实道路的视角与天气不够」:真采集只有自车前视一个视角,重建之后可以渲染出「同一路口左转 30 度的视角」「低 20 cm 的贴地视角」;这类样本用引擎做要重搭场景,用 splat 只是把相机位姿矩阵换个位置。
与 5.4.1 的两条路线放在一起比:
| 维度 | 引擎合成(CARLA/UE5) | 3DGS 重建 + 新视角 | 判断依据 |
|---|---|---|---|
| 场景来源 | 美术资产与路网数据,从零搭 | 真实视频重建,拍什么有什么 | 要真实道路的精确布局用 3DGS |
| 新视角范围 | 任意,但题材受资产限制 | 只在采集轨迹邻域内可信,超出会糊 | 大范围新视角用引擎 |
| 动态物体 | 交通流可控 | 采集时的人和车会被「焊」进场景成幽灵拖影 | 要有动态参与者的场景用引擎 |
| 标注真值 | 引擎直出像素级掩码与深度 | 只有渲染图,掩码要重投影或人工补 | 要深度/语义真值优先引擎 |
| 成本 | 场景搭建人力 | 一段视频 + 一次训练(分钟级到小时级) | 小团队补数据用 3DGS |
| 风格差距 | 大,sim2real 负迁移风险高 | 小,纹理与光照就是真实的 | 怕风格偏差用 3DGS |
重建工具链与第 06 章完全一致(第06章 - 构建 3DGS),本章只取「重建 + 渲染新视角」两段,不重复讲 COLMAP 组网与查看器:
# 1) 重建:视频抽帧 + COLMAP 稀疏 + 3DGS 训练(数据准备与命令细节见第 06 章)
python scripts/colmap_from_video.py --video data/road.mp4 --out data/road_colmap
python examples/simple_trainer.py default --data_dir data/road_colmap --result_dir runs/road_pt
# 权重文件名与渲染入口以所用 gsplat 版本的 README 为准
# 2) 渲染新视角:相机在自车轨迹上横移与抬高,参数是论文里的「相机扰动」范围
python scripts/render_novel_views.py --ckpt runs/road_pt/ckpts/last.pt \
--trajectory data/road_colmap/traj.json --lateral -1.5,0,1.5 --height 0,0.3 \
--out data/synth_3dgs/frames
新视角的相机位姿不用手推公式,在原位姿的左乘/右乘上做一次刚体平移即可:
import numpy as np
def offset_camera(c2w, lateral_m, up_m):
"""在原相机位姿上叠加横向/垂向平移。c2w 为 4x4 相机到世界矩阵(OpenCV 约定)。
相机系 x 右、y 下、z 前,所以「上」是 -y。"""
t = np.array([lateral_m, -up_m, 0.0])
out = c2w.copy()
out[:3, 3] = c2w[:3, 3] + c2w[:3, :3] @ t
return out
渲染出的帧只有图像,标签按 5.4.3 的思路补:先用 5.2 的检测器在原始真实帧上出结果,配合真实标注做 2D 到 3D 对齐,再把几何按新位姿重投影回新视角;重投影后要抽检,横向偏移超过 1.5 m 时远处车道线的透视误差会被放大。
与 5.4.4 的策略配套:3DGS 帧更适合当风格与视角补充而不是主力——同一路段的重复性太强,直接混训会过拟合这段路。建议控制在训练集的 10% 以内,验证集里不放合成帧。
坑:
- 新视角超出采集包围盒。渲染相机一旦离开采集轨迹的覆盖范围,splat 会糊成大色块,投出来的掩码边界跟着错;把渲染视角限制在采集轨迹左右各 2 m、高度 ±0.5 m 内。
- 动态物体成幽灵。3DGS 假设场景静态,路过的人和车会被重建成一团悬浮高斯球;采集挑车流稀疏时段,或用 masks 目录在训练前把动态目标剔掉。
- 把重建误差记到检测模型头上。3DGS 的几何精度近处是厘米级、随距离掉得快;用它算米制误差前,先和真实车道线标尺比一次,把重建偏差单独记录。
5.5 基于 Cesium 的导航控制仿真
前四节都在算「线在哪」,这一节回答「车开得对不对」。把导航线放进 Cesium 的三维场景里,配合 Pure Pursuit 控制律逐帧推进位姿,就能在没有真车的情况下看到横向误差怎么积累、控制参数怎么影响跟随效果。裸 Cesium API 的用法站内 CesiumJS 教程 讲得比本章细,这里只给仿真必需的子集:坐标互转、位姿驱动、控制律、误差记录。
5.5.1 场景初始化与坐标系约定
Cesium 用 WGS84 地心坐标(ECEF,Cesium.Cartesian3),车辆的位姿在局部东北天坐标(ENU)里算最方便,两者用 Cesium.Transforms.eastNorthUpToFixedFrame 互转。约定局部原点为起点的地面点,局部轴 x 为东、y 为北、z 为上,航向角从 x 轴逆时针起算。
import * as Cesium from "cesium";
// 仿真起点:经度、纬度、地面高(米)
const ORIGIN = Cesium.Cartesian3.fromDegrees(116.3912, 39.9073, 0.0);
// 局部 ENU -> ECEF 的固定变换,所有位姿都在局部坐标里算完再转
const enuToFixed = Cesium.Transforms.eastNorthUpToFixedFrame(ORIGIN);
const viewer = new Cesium.Viewer("cesiumContainer", {
terrainProvider: await Cesium.createWorldTerrainAsync(), // 地形 + 卫星影像
baseLayer: Cesium.ImageryLayer.fromProviderAsync(
Cesium.ArcGisMapServerImageryProvider.fromBasemapType(
Cesium.ArcGisBaseMapType.SATELLITE)),
animation: false, timeline: false, baseLayerPicker: false,
geocoder: false, sceneModePicker: false, navigationHelpButton: false,
});
// 局部 (east, north, up) 米 -> Cesium.Cartesian3
function localToCartesian(e, n, u) {
const local = new Cesium.Cartesian3(e, n, u);
const m = Cesium.Matrix4.multiplyByPoint(enuToFixed, local, new Cesium.Cartesian3());
return m;
}
// 导航线:局部坐标点数组 [{e, n}, ...],转成世界坐标并画出
function addNavLine(viewer, navLocal, color = Cesium.Color.CYAN) {
const positions = navLocal.map(p => localToCartesian(p.e, p.n, 1.0));
return viewer.entities.add({
name: "nav_line",
polyline: {
positions, width: 4, clampToGround: true,
material: new Cesium.PolylineGlowMaterialProperty({
glowPower: 0.2, color,
}),
},
});
}
这里有个容易踩的点:clampToGround: true 与手动给高度是互斥的,贴地后高度参数被忽略。导航线要贴地,车辆要离地,两者分开处理:线贴地、车用 heightReference: Cesium.HeightReference.CLAMP_TO_GROUND 或用实测高程。
两条版本纪律先写在这里,后面 5.5.6 还会用到。
(1)本章的 Cesium 口径统一为 CesiumJS 1.147(2026-11 发布,见 官方 CHANGES)。createWorldTerrainAsync、PolylineGeometry、PolylineColorAppearance 这类 API 在近几个版本里有过签名与参数调整,写法以 1.147 的 ref-doc 为准,不要照抄几年前的博客。
(2) 地形之上要叠的两层几何——3D Tiles 1.1 瓦片集与带 LOD 的 splat tileset——都走 Cesium3DTileset 这一条加载管线,分层方法与参数见 5.5.6。
5.5.2 位姿驱动:两种写法与各自代价
Cesium 提供两条路径驱动实体位置:Cesium.SampledPositionProperty(预先把整条轨迹采样好)与 Cesium.CallbackProperty(每帧回调实时算)。前者交给 Cesium 自己插值,性能好;后者适合控制律在线求解。
| 写法 | 适用场景 | 每帧开销 | 能否实时改轨迹 | 注意 |
|---|---|---|---|---|
| SampledPositionProperty | 回放已有轨迹、离线评估 | 极低,插值在渲染线程 | 否,需重建 | 采样间隔 > 0.1 s 时弯道会切角 |
| CallbackProperty + 定时器 | 闭环仿真、控制律在线计算 | 中,每帧调用 JS | 是 | 回调抛异常会静默冻结实体 |
| Entity.position 手动赋值 | 调试单步 | 低 | 是 | 与 clock 不同步时会抖动 |
// 写法 A:把离线算好的轨迹喂给 Cesium,用于回放与截图
function playbackTrajectory(viewer, samples) {
// samples: [{ t: 秒, e, n, u, heading }, ...]
const start = Cesium.JulianDate.fromIso8601(samples[0].isoTime);
const stop = Cesium.JulianDate.addSeconds(start, samples[samples.length - 1].t,
new Cesium.JulianDate());
Object.assign(viewer.clock, { startTime: start.clone(), stopTime: stop,
currentTime: start.clone(), clockRange: Cesium.ClockRange.LOOP_STOP, multiplier: 1.0 });
const posProp = new Cesium.SampledPositionProperty();
for (const s of samples) {
const t = Cesium.JulianDate.addSeconds(start, s.t, new Cesium.JulianDate());
posProp.addSample(t, localToCartesian(s.e, s.n, s.u));
}
posProp.setInterpolationOptions({ interpolationDegree: 3,
interpolationAlgorithm: Cesium.LagrangePolynomialApproximation });
return viewer.entities.add({
name: "ego",
position: posProp,
orientation: new Cesium.VelocityOrientationProperty(posProp),
model: { uri: "/models/vehicle.glb", minimumPixelSize: 32 },
});
}
// 写法 B:实时闭环,每帧由控制律推进一步
const state = { e: 0, n: 0, u: 1.0, yaw: 0, v: 8.0, t: 0 };
function attachLiveEgo(viewer) {
const pos = new Cesium.CallbackProperty((time, result) =>
Cesium.Cartesian3.clone(localToCartesian(state.e, state.n, state.u), result), false);
const ori = new Cesium.CallbackProperty((time, result) => {
// 航向转四元数:绕局部 z 轴(天向)旋转 yaw + 车头朝向修正
const hpr = new Cesium.HeadingPitchRoll(state.yaw + Math.PI / 2, 0, 0);
return Cesium.Transforms.headingPitchRollQuaternion(
Cesium.Cartesian3.ZERO, hpr, Cesium.Ellipsoid.WGS84, undefined, result);
}, false);
return viewer.entities.add({ name: "ego_live", position: pos, orientation: ori,
point: { pixelSize: 10, color: Cesium.Color.RED } });
}
5.5.3 控制律:Pure Pursuit 与 Stanley
| 控制律 | 核心输入 | 参数 | 优点 | 失灵场景 |
|---|---|---|---|---|
| Pure Pursuit | 前视点横向偏移 | 前视距离 Ld | 直道稳、实现极简 | 急弯切内道,Ld 过大 |
| Stanley | 前轴横向误差 + 航向误差 | 增益 k | 弯道跟踪好、无稳态误差 | 低速时对噪声敏感 |
Pure Pursuit 的几何关系可以直接写成代码:在前方 Ld 处取导航线上的一点,车辆到该点的圆弧曲率 kappa = 2·sin(alpha)/Ld(alpha 为车头方向与前视点方向的夹角),转向角 delta = atan(kappa · 轴距)。
const WHEELBASE = 2.7; // 轴距,m
// 从车辆坐标导航线(x 前, y 左)里按 x 插值取前视点
function lookupByX(navXY, targetX) {
let best = navXY[0];
for (let i = 1; i < navXY.length; i++) {
const a = navXY[i - 1], b = navXY[i];
if (a.x <= targetX && b.x >= targetX) {
const k = (targetX - a.x) / Math.max(b.x - a.x, 1e-6);
return { x: targetX, y: a.y + k * (b.y - a.y) };
}
}
return best;
}
function purePursuit(navXY, v) {
const Ld = Math.max(3.0, 1.2 * v); // 前视距离随速度增长
const target = lookupByX(navXY, Ld); // 车辆坐标下 (x, y)
const alpha = Math.atan2(target.y, target.x); // 目标方位角(y 向左为正)
const curvature = 2 * Math.sin(alpha) / Math.hypot(target.x, target.y);
const delta = Math.atan(curvature * WHEELBASE);
return { delta, target, Ld };
}
function stanley(navXY, yaw, v, k = 0.8, delta0 = 0) {
// 预瞄 1.0 m 处的参考点,算横向误差与航向误差
const near = lookupByX(navXY, 1.0);
const speedK = Math.max(v, 1.0); // 低速时抑制噪声
const delta = delta0
+ Math.atan2(k * near.y, speedK)
+ wrapPi(Math.atan2(near.y, near.x) - yaw);
return Math.max(-0.5, Math.min(0.5, delta));
}
function wrapPi(a) {
while (a > Math.PI) a -= 2 * Math.PI;
while (a < -Math.PI) a += 2 * Math.PI;
return a;
}
两个参数按速度调,不要当常数:Pure Pursuit 的 Ld 若固定 5 m,高速时会滞后、低速时会在直道上画龙;Stanley 的 k 在 0.5–1.2 之间,路面平整取大值,有侧风与坡度取小值。
5.5.4 闭环步进与误差记录
闭环的骨架是「定时步进 + 二自由度车辆模型」。用 Cesium.JulianDate 推进时钟(而不是 requestAnimationFrame 的墙钟),仿真时间就与渲染帧率解耦,同一份轨迹在任何机器上跑出来的误差一样,这是能复现评估的前提。
const DT = 0.05; // 仿真步长 50 ms,与真实控制周期一致
const record = []; // 每步记录 {t, e_m, heading_err}
function stepVehicle(st, delta, v, dt) {
// 自行车模型:后轴中心沿航向运动,前轮转角 delta
st.yaw += (v * Math.tan(delta) / WHEELBASE) * dt;
st.e += v * Math.cos(st.yaw) * dt;
st.n += v * Math.sin(st.yaw) * dt;
st.t += dt;
return st;
}
function createSimLoop(viewer, navXYInVehicleFrame, opts) {
const { durationSec = 60, speed = 8.0 } = opts;
let elapsed = 0;
const removeListener = viewer.scene.postRender.addEventListener(() => {
if (elapsed >= durationSec) { removeListener(); return; }
// 导航线在车辆坐标系里已算好,直接用它求控制量
const { delta } = purePursuit(navXYInVehicleFrame(state.t), speed);
stepVehicle(state, delta, speed, DT);
elapsed += DT;
// 横向误差:当前点到导航线的最近距离(导航线已转到局部 ENU 近似)
const err = nearestLateralError(navXYInVehicleFrame(state.t), state.e, state.n);
record.push({ t: state.t, e_m: err, heading_err: delta });
});
return record;
}
function nearestLateralError(navLocal, e, n) {
let best = Infinity;
for (let i = 1; i < navLocal.length; i++) {
const a = navLocal[i - 1], b = navLocal[i];
const vx = b.e - a.e, vy = b.n - a.n, wx = e - a.e, wy = n - a.n;
const len2 = vx * vx + vy * vy;
const t = len2 > 0 ? Math.max(0, Math.min(1, (wx * vx + wy * vy) / len2)) : 0;
best = Math.min(best, Math.hypot(wx - t * vx, wy - t * vy));
}
return best;
}
function summarize(record) {
const errs = record.map(r => r.e_m);
const mean = errs.reduce((s, x) => s + x, 0) / errs.length;
const rmse = Math.sqrt(errs.reduce((s, x) => s + x * x, 0) / errs.length);
const p95 = errs.slice().sort((a, b) => a - b)[Math.floor(errs.length * 0.95)];
return { n: errs.length, mean: +mean.toFixed(3), rmse: +rmse.toFixed(3), p95: +p95.toFixed(3) };
}
误差曲线的呈现不必引入图表库:把 record 里的横向误差按时间画成两条折线(误差值 + 零轴),贴在页面右下角的小 canvas 上即可;更省事的做法是把 summarize() 的结果和逐帧误差打成 JSON,用 5.6 的 headless 流程批量跑参数扫。
5.5.5 可视化增强与性能红线
仿真页面卡不卡,几乎只取决于实体数量与几何复杂度,不取决于坐标计算。三条硬红线:Polyline 点数控制在 2000 以内、动态实体少于 200 个、每帧不要用 entities.add 建新对象。
| 做法 | 代价 | 替代方案 | 适用 |
|---|---|---|---|
| 逐帧 add/remove 实体 | 每帧 GC,掉帧明显 | 复用实体,只改 position | 所有动态对象 |
| 误差带用多个 Entity 带状面 | 实体数爆炸 | 单个 Primitive + 顶点色 | 沿线的误差可视化 |
| 预测轨迹用 CallbackProperty 逐点算 | JS 主线程压力 | SamplingProperty 预采样 + 每 0.5 s 更新 | 长轨迹 |
| 相机跟随用 lookAt 每帧调用 | 与用户交互冲突 | viewer.trackedEntity |
演示与录屏 |
| 影像/地形全屏高清 | 显存与带宽 | viewer.scene.globe.maximumScreenSpaceError = 2 |
城市级场景 |
// 相机跟随:跟踪实体比每帧 lookAt 稳,且不会抢用户交互
viewer.trackedEntity = egoEntity;
// 误差带:改用 Primitive + 顶点色,几百个采样点也只有一次 draw call
function addErrorBand(viewer, samples, maxErr = 0.8) {
const positions = [], colors = [];
for (const s of samples) {
positions.push(localToCartesian(s.e, s.n, 0.2));
const t = Math.min(s.e_m / maxErr, 1.0); // 0=贴线, 1=偏出 maxErr
colors.push(t, 0.15, 1.0 - t, 0.85); // 绿 -> 红(t 越大越红)
}
const geom = new Cesium.PolylineGeometry({ positions, width: 8, colors,
vertexFormat: Cesium.PolylineColorAppearance.VERTEX_FORMAT });
const inst = new Cesium.GeometryInstance({ geometry: geom });
return viewer.scene.primitives.add(new Cesium.Primitive(
{ geometryInstances: inst, appearance: new Cesium.PolylineColorAppearance() }));
}
性能数据用浏览器 Performance 面板看 render 与 postRender 的耗时,不要在页面里自统计,统计代码本身会成为新的开销。验收仿真页面的标准写三条:60 s 仿真过程中平均帧率 ≥ 30、横向误差 RMSE ≤ 0.25 m、参数改一次能在 2 分钟内重跑出新的误差曲线。
坑:
- 航向角多加了 90°。Cesium 的
HeadingPitchRoll的 heading 是从正北顺时针起算,局部 ENU 的 x 轴是东,两者差 90°;漏掉这个修正,车头会横着走而位置正确。- 时钟推进用墙钟。用
Date.now()算步长,帧率一抖步长就变,误差曲线不可复现;一律用固定的DT累加仿真时间。- 贴地高度与 ClampToGround 冲突。既给高度又开贴地时,Cesium 以贴地为准,看起来车陷进了地面;要么全贴地,要么用采样高程。
- postRender 里改实体属性引发递归。在
postRender回调里entities.add/remove会在下一帧触发重渲染,形成堆积;新增对象放到scene.preUpdate或一次性预建。
5.5.6 地形 + 3D Tiles 1.1 + splat:三层场景怎么叠
5.5.1 的初始化只给了地形与影像,真实项目里往往还要叠两三层几何:城市建筑用 3D Tiles 1.1 瓦片集,街道细节(树、杆、店招、路面起伏)用带 LOD 的 3DGS splat tileset。三层混在一个场景里,最常见的故障不是加载失败,而是分层顺序与精度档位没定,同一位置两层几何互相打架。
分层原则按「谁负责提供地平面」来定,各层职责不重叠:
| 层 | 内容 | 数据形态 | 精度定位 | 本章用途 |
|---|---|---|---|---|
| 底 | 地形 + 影像 | Quantized-Mesh / 影像瓦片 | 米级,负责高程与地表 | 导航线贴地、误差的高程基准 |
| 中 | 建筑与路网 | 3D Tiles 1.1(带 metadata 与隐式瓦片) | 分米级,负责体积与遮挡 | 判断导航线是否被建筑遮挡 |
| 顶 | 街道细节 | splat tileset(带层次 LOD) | 近景厘米级,远处糊 | 录屏、评审时的视觉真实度 |
| 辅助 | 导航线 / 车辆 | Entity / Primitive | 无 | 仿真主体,不参与化解层级 |
3D Tiles 1.1 的两个变化对本章有用:一是 metadata,瓦片可以带属性(road_id、lane_count、speed_limit),仿真时按导航线所在瓦片的属性取限速,比另开一份 JSON 省事;二是 隐式瓦片(implicit tiling),瓦片树不再逐块写 json,城市级数据加载时的请求数明显下降。
// 版本口径:CesiumJS 1.147(2026-11 发布);API 签名与扩展支持以当前 ref-doc 为准
const [buildings, streetSplat] = await Promise.all([
Cesium.Cesium3DTileset.fromUrl("/tiles/city_3dtiles11/tileset.json", {
maximumScreenSpaceError: 8, // 建筑层不需要 1 px 精度
shadows: Cesium.ShadowMode.ENABLED,
}),
Cesium.Cesium3DTileset.fromUrl("/tiles/street_splat/tileset.json", {
maximumScreenSpaceError: 4, // splat 层比建筑层细一档
}),
]);
viewer.scene.primitives.add(buildings);
viewer.scene.primitives.add(streetSplat);
// 远景关掉 splat:两层同时抢像素时,先牺牲街景细节
viewer.scene.preUpdate.addEventListener(() => {
streetSplat.show = viewer.camera.positionCartographic.height < 350.0;
});
viewer.scene.globe.maximumScreenSpaceError 与瓦片集的 maximumScreenSpaceError 是两套独立参数,调一个不影响另一个:地形调细了但瓦片集没跟,画面会呈现「地面很细、房子很糊」。
坑:
- 贴地高度打架。地形、3D Tiles、splat 各自的高程基准不同;导航线用
clampToGround贴地形,却会被 splat 的路面盖住。仿真时把导航线抬 0.05–0.1 m,或明确classificationType的遮挡关系。- 两层 LOD 不同步。splat 的细节层级由它自己的 tileset 决定;相机推近时建筑瓦片已经细了、splat 还在粗层,画面会出现「近处糊、远处清」的反直觉组合。两层的
maximumScreenSpaceError拉开一档(建筑 8、splat 4)比调成同一个值更容易稳定。- 全量加载吃爆显存。城市级 3D Tiles 与 splat 同时全屏时显存吃紧;先用
globe.maximumScreenSpaceError = 2与瓦片集的最大 SSE 把请求量压住,再谈画质。
5.6 用 DeepSeek Harness 加速实验
前面五节的代码量不小,但真正耗时的是实验组织:同一份训练脚本要在十几组超参上跑、几十个失效样本要归因、仿真页面要反复改。这三件事都能交给 DeepSeek Harness(命令行 dsh,下称 DSH)承担,前提是把任务写成可验收的形式。
5.6.1 生成训练脚本与配置矩阵
不要让 DSH「写个训练脚本」,而是给它输入输出与验收标准。有效的任务书包含四要素:入口命令、配置来源、产出路径、验收口径。
# 1) 用 headless 模式跑一个明确的任务,跑完即退
dsh --profile headless "读 configs/train_base.yaml,写 scripts/train.py:
入口 python scripts/train.py --config <path> --out runs/<name>;
必须支持 AMP、断点续训、每 epoch 写 metrics.json(字段 iou_0/iou_1/iou_2/loss);
不要改 configs/ 下已有文件。写完用 python -c 'import ast;ast.parse(open(\"scripts/train.py\").read())' 自检语法。"
# 2) 用 DSH 生成参数矩阵,而不是手抄 12 行 yaml
dsh --profile headless "生成 configs/matrix/ 下 12 个 yaml:
lr ∈ {1e-4,3e-4,6e-4} × base ∈ {24,32} × aug ∈ {light,heavy};
其余键与 configs/train_base.yaml 完全一致,只覆盖不同的三个键。生成后打印前 3 行。"
# 3) headless + json 事件流,适合接 CI
dsh --profile headless --json "跑 scripts/train.py --config configs/matrix/lr3e-4_base32_auglight.yaml --out runs/m1,失败时打印最后 40 行日志" > runs/m1.events.jsonl
配置矩阵生成后,批量跑不要写复杂的调度器,用 for 加失败重试就够,关键是把每个 run 的指标落成 JSON,最后统一汇总:
import json, subprocess, sys
from pathlib import Path
RUNS = Path("runs"); MATRIX = sorted(Path("configs/matrix").glob("*.yaml"))
def run_one(cfg: Path, retries: int = 1) -> dict:
out = RUNS / cfg.stem; out.mkdir(parents=True, exist_ok=True)
cmd = [sys.executable, "scripts/train.py", "--config", str(cfg), "--out", str(out)]
for attempt in range(retries + 1):
p = subprocess.run(cmd, capture_output=True, text=True)
if p.returncode == 0:
m = json.loads((out / "metrics.json").read_text(encoding="utf-8"))
return {"cfg": cfg.stem, "ok": True, **m}
last_err = (p.stderr or p.stdout).strip().splitlines()[-1:]
print(f"[retry {attempt}] {cfg.stem}: {last_err}")
return {"cfg": cfg.stem, "ok": False, "err": last_err}
if __name__ == "__main__":
rows = [run_one(c) for c in MATRIX]
(RUNS / "summary.json").write_text(json.dumps(rows, ensure_ascii=False, indent=2), encoding="utf-8")
ok = [r for r in rows if r["ok"]]; best = max(ok, key=lambda r: r.get("iou_1", 0)) if ok else None
print(f"完成 {len(ok)}/{len(rows)},最佳:{best and best['cfg']}")
5.6.2 用 DSH 做失效样本归因
指标只能告诉你「变差了」,不能告诉你「为什么」。做法是把失效帧的结构化描述和统计摘要一起交给 DSH,让它按假设—证据的方式给归因,再由你验证。
# 先把失效帧整理成一张表:文件名、误差、曲率、光照、是否遮挡
python scripts/collect_failures.py --manifest data/lane/meta/manifest_val.json \
--pred runs/m1/pred --out runs/m1/failures.json --topk 200
# 再让 DSH 做归因,要求它给出可验证的假设而不是泛泛而谈
dsh --profile headless "读 runs/m1/failures.json 与 runs/m1/metrics.json。
按『弯道/逆光/遮挡/标注疑似错误』四类给每条失效帧打标签,
输出 runs/m1/failure_triage.json(字段:frame_id,label,evidence,confidence);
只依据文件里的数值下结论,缺证据就标 confidence<0.5。最后给 3 条能在 1 小时内验证的改进动作。"
归因结果必须自己抽检 20 条,因为 DSH 看不到图像,它只能基于你给的数值推断;confidence < 0.5 的那部分通常意味着「你给的字段不足以支撑判断」,这时该补字段(比如加一个「本帧是否被大车遮挡」的标记),而不是让它继续猜。
5.6.3 用 DSH 做仿真页面原型
Cesium 页面的样板代码多,让 DSH 先出一版能跑的原型,再人工按 5.5.5 的性能红线改,比从零写快得多。任务书里把约束写死,避免它生成一堆 Entity:
dsh --profile headless "在 gis/ 下新建一个独立的 cesium 仿真页(不要动现有教程文件):
用 CDN 引入 cesium 1.147,初始化 Viewer(地形 + 影像);
从 data/nav_sample.json 读导航线(局部 ENU 坐标),画 glow polyline;
用 CallbackProperty 驱动一个红色 point 沿导航线以 8 m/s 移动;
页面上显示当前速度与横向误差,误差用纯文本,不引第三方库;
实体总数不超过 200,禁止每帧 add/remove。写完后把关键 API 版本号列在注释里。"
坑:
- 任务书没有验收标准。只说「写个训练脚本」,DSH 会给你一版能跑但不能复现的脚本;入口命令、字段名、失败行为都要写进任务书。
- 让它改已有关键文件。headless 任务里没写明「不要动某文件」时,它可能顺手重构;把
不要改 configs/ 与 data/写进任务书,或把沙箱设成 read-only 先让它只读分析。- 把图像丢给纯文本模型。DSH 的 headless 模式不吃图像,失效分析要走结构化字段;要图像侧的证据,先跑脚本把误差曲线、掩码叠加图导出成数值表。
- 依赖幻觉的 API。Cesium 版本间 API 变动不小,让它生成代码后必须本地跑一遍并核对控制台报错,别直接上线。
5.7 本章小结
- 链路拆成感知、拟合、投影、控制四段,接口按 5.1.1 的表写死,换模型不换契约。
- 像素、相机、车辆、BEV 四种坐标不能混用;米制误差只能在车辆坐标系里算。
- 相机外参先用「三个地面点投到同一条收敛直线」自检,再谈标定精度。
- IPM 只对地面成立,有效距离截到 25–30 m,坡道段改用栅格路径。
- YOLO 方案快、标注便宜;关键点检测是性价比最高的一种检测目标设计。
- UNet 方案对小目标与遮挡更稳,损失用 CE+Dice,Dice 分支留在 fp32。
- 导航线评估四个口径同时看:像素 IoU、平均偏移、弯道误差、失效帧比例。
- 合成数据补长尾,占比不超过 30%(3DGS 新视角建议 ≤10%),所有指标只在真实验证集报告。
- Cesium 仿真用固定 DT 推进仿真时钟,误差曲线才可复现;性能红线是实体数与每帧分配。
-
DSH 负责实验组织(矩阵、批量、归因、原型),验收标准必须写进任务书。
- 2026 起检测头默认用 YOLO26 的端到端头(
nms=False),换来的主要是延迟确定性而不是 mAP;开关在推理与导出两处都要带。 - 合成数据多了一条 3DGS 重建路线,适合补视角与风格,不适合补动态交通与深度真值;渲染视角限制在采集轨迹邻域内。
- CesiumJS 口径是 1.147,3D Tiles 1.1 + splat 按「地形 / 建筑 / 街道细节」三层叠,两层的
maximumScreenSpaceError要拉开一档。
5.8 动手练习
五个练习的判分口径先统一,避免「跑出来了」和「达标」混为一谈:
| 练习 | 输入 | 产出物 | 合格线 |
|---|---|---|---|
| 1 IPM 标定 | 一帧图像 + 安装参数 | 两个验收数字 | 间距 35±3 px、横向 ≤ 8 px |
| 2 阈值敏感性 | 200 帧验证集 | 三组指标 | 能解释失效帧比例的变化方向 |
| 3 UNet 导航线 | 一帧分割掩码 | 叠加图 + 偏移 | 平均偏移 ≤ 0.25 m |
| 4 随机化连续性 | 5 段 × 20 帧参数 | 参数表 + 连帧统计 | 相邻帧温差 ≤ 2° |
| 5 闭环 RMSE | createSimLoop + 60 s 线 | 两条误差曲线 | 给出条件化的参数建议 |
练习 1:把 IPM 标定的两个验收标准跑出来
输入:一张 1280×720 的前视图像 + 5.1.2 的安装参数(pitch=3°、h=1.35 m、f=900)。
要求:用 ipm_from_mount 生成 H,投一条 3.5 m 宽车道的左右线到 BEV,量两点间距;再投车前 20 m 的中心点,量它与 BEV 中线的横向偏差。
验收:间距 35±3 px、横向偏差 ≤ 8 px;把两组数字写进报告,任一不达标要改参数重投一次。
练习 2:给 YOLO 后处理换一套阈值并解释指标变化
输入:5.2.2 的后处理代码,跑在至少 200 帧验证集上。
要求:把 thresh 从 6.0 改到 4.0 与 10.0 各跑一遍,记录像素 IoU、平均偏移、失效帧比例。
验收:给出三组数字并说明「为什么 thresh 变大时失效帧比例反而可能下降」,结论要能对上 5.2.4 的四个口径。
练习 3:用 UNet 掩码复现一条导航线
输入:任意一帧分割掩码(可用 5.3.4 的 drivable_to_graph_free 直接生成)。
要求:骨架化 + 栅格最短路径,输出像素坐标导航线,再用 5.2.3 的 IPM 转到车辆坐标。
验收:画一张叠加图(原图 + 掩码 + 导航线),并用 nav_errors 与人工标注的中心线算平均偏移,要求 ≤ 0.25 m。
练习 4:合成场景随机化并检查连续性
输入:5.4.2 的 SceneRandomizer。
要求:生成 5 段 × 20 帧的随机化参数,检查同一段内 sun_elevation_deg 是否连续(相邻帧差 ≤ 2°),以及相机内参是否每段只随机一次。
验收:输出一张参数表 + 一段连帧检查的通过/失败统计,失败项要能定位到具体帧号。
练习 5:Cesium 闭环跑出横向误差 RMSE
输入:5.5.4 的 createSimLoop 与一条 60 s 的导航线。
要求:分别跑 Ld = max(3.0, 1.2v) 与 Ld = 5.0 两套参数,记录 RMSE 与 P95。
验收:给两条误差曲线(或逐帧误差表)与 summarize() 结果,说明哪套参数在直道/弯道分别更好,结论要写成「在 XX 条件下用 A,否则用 B」。
5.9 延伸阅读
| 方向 | 推荐材料 | 本章用法 |
|---|---|---|
| 仿真引擎 | CARLA(UE5 版本)文档、Isaac Sim 文档 | 5.4 合成数据生成 |
| 数据集与基准 | TuSimple Benchmark、KITTI | 5.1 数据来源、5.2 评估 |
| 检测与端到端 | YOLO26 文档与论文(arXiv:2606.03748) | 5.2.5 端到端检测头与延迟对比 |
| 分割网络 | U-Net 论文、DeepLabV3+、MMSegmentation | 5.3 网络结构与训练 |
| 三维重建与合成 | gsplat、SuperSplat | 5.4.5 3DGS 新视角渲染 |
| 可视化与仿真 | CesiumJS 1.147 文档、3D Tiles 1.1 规范 | 5.5 三维回放与分层加载 |
- CARLA Simulator 官网:UE5 分支的文档与发行说明入口,具体版本号以官方 CHANGELOG 为准。
- TuSimple Lane Detection Benchmark:车道线检测最常用的公开基准与评测脚本。
- KITTI Dataset:相机、激光雷达与标定真值的经典数据集,用来校核投影与外参。
- U-Net 论文(arXiv:1505.04597):编码-解码 + 跳跃连接,本章 5.3 的结构来源。
- Ultralytics YOLO26 文档:双检测头、
nms=False端到端推理与五档模型表的官方说明。 - gsplat:3DGS 训练与光栅化实现,5.4.5 重建与新视角渲染的后台。
- CesiumJS 官方文档:
Viewer、SampledPositionProperty、Cesium3DTileset等 API 的权威说明。 - 3D Tiles 规范仓库:1.1 版的 metadata 与隐式瓦片,5.5.6 分层加载的依据。