第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 行的大函数更容易排错:

  1. 候选点收集:检测框中心 → 像素点,按类别落到 left/right 两个桶;
  2. 异常点剔除:同一 v 行上 u 距离 < 12 px 的两点只保留一个(重复检测);
  3. RANSAC 拟合:对每桶单独拟合 u = a·v³ + b·v² + c·v + d,三次项只在弯道有意义,直道上 a≈0;
  4. 左右配对与中心线:在公共 v 栅格上取两条拟合线的中点;
  5. 平滑:滑动平均 + 与上帧的指数平滑,抑制逐帧抖动。
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 本章小结

  1. 链路拆成感知、拟合、投影、控制四段,接口按 5.1.1 的表写死,换模型不换契约。
  2. 像素、相机、车辆、BEV 四种坐标不能混用;米制误差只能在车辆坐标系里算。
  3. 相机外参先用「三个地面点投到同一条收敛直线」自检,再谈标定精度。
  4. IPM 只对地面成立,有效距离截到 25–30 m,坡道段改用栅格路径。
  5. YOLO 方案快、标注便宜;关键点检测是性价比最高的一种检测目标设计。
  6. UNet 方案对小目标与遮挡更稳,损失用 CE+Dice,Dice 分支留在 fp32。
  7. 导航线评估四个口径同时看:像素 IoU、平均偏移、弯道误差、失效帧比例。
  8. 合成数据补长尾,占比不超过 30%(3DGS 新视角建议 ≤10%),所有指标只在真实验证集报告。
  9. Cesium 仿真用固定 DT 推进仿真时钟,误差曲线才可复现;性能红线是实体数与每帧分配。
  10. DSH 负责实验组织(矩阵、批量、归因、原型),验收标准必须写进任务书。

  11. 2026 起检测头默认用 YOLO26 的端到端头(nms=False),换来的主要是延迟确定性而不是 mAP;开关在推理与导出两处都要带。
  12. 合成数据多了一条 3DGS 重建路线,适合补视角与风格,不适合补动态交通与深度真值;渲染视角限制在采集轨迹邻域内。
  13. 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 三维回放与分层加载