LaneAF Robust Multi-Lane Detection with Affinity Fields

Task: Lane Detection
Method: Affinity Fields, Instance Segmentation
Venue: arXiv
Year: 2021
Paper: https://arxiv.org/abs/2103.12040
Code: https://github.com/sel118/LaneAF

摘要

本研究提出了一种车道检测方法,通过同时预测二值分割掩码与逐像素亲和场来实现实例分离。亲和场结合二值掩码,可在后处理阶段将车道像素沿水平和垂直方向聚类为各自对应的车道实例。该聚类过程通过简单的逐行解码实现,计算开销极小,且无需预设车道数量上限。相较于以往的视觉聚类方法,这种聚类形式更具可解释性,便于分析和定位错误来源。在主流车道线检测数据集上的定性与定量结果均验证了模型检测和聚类车道线的有效性与鲁棒性。LaneAF 在具有挑战性的 CULane 数据集和新发布的 Unsupervised LLAMAS 数据集上均达到了新的最先进水平。

核心论点:用像素关联(亲和场)替代全局聚类,通过行级解码实现可变车道数的高效检测。本质是将 $O(N^2)$ 的聚类问题降维为 $O(N)$ 的序列关联问题。

问题与动机

车道线检测面临的根本矛盾:如何在不预设车道数量的前提下,准确分离相邻的车道线实例?

现有方案的困境

方法类型 代表 核心问题 F1-Score
分割+聚类 LaneNet 需要嵌入空间学习,Mean-Shift 聚类开销 ~50ms 71.3%
曲线回归 PolyLaneNet 必须预设车道数上限,参数化表达能力有限 75.3%
关键点检测 PINet 需要预定义车道数,关键点匹配复杂 74.4%

三个无法调和的矛盾

  1. 可变车道数 vs 预设数量上限(2-6 条如何兼容?)
  2. 精确实例分割 vs 全局聚类开销(Mean-Shift 太慢)
  3. 简单后处理 vs 复杂匹配策略(曲线拟合+NMS)

车道线有强垂直连续性,不需要全局聚类,可以逐行关联。

核心洞察

LaneAF 基于三个关键观察重新设计检测流程:

洞察 1:像素关联比嵌入聚类更直接

传统方法:学习高维嵌入 → 全局聚类 → 实例分割
LaneAF:直接预测相邻像素是否属于同一实例(二值关系)

两种亲和场定义

$$\text{HAF}(x, y) = \begin{cases} 1, & \text{pixel}(x,y) \text{ 和 } (x+1,y) \text{ 属于同一车道} \\ 0, & \text{otherwise} \end{cases}$$ $$\text{VAF}(x, y) = \begin{cases} 1, & \text{pixel}(x,y) \text{ 和 } (x,y-1) \text{ 属于同一车道} \\ 0, & \text{otherwise} \end{cases}$$

优势:二值关系比高维嵌入更容易学习,且可直接可视化调试。

洞察 2:车道线垂直连续性 = 序列关联问题

车道线从底部到顶部垂直延伸,可转化为”上一行车道如何连接到当前行”的序列问题。

时间复杂度

  • 全局聚类:$O(N^2)$,N 为所有车道线像素数(~1000-2000)
  • 行级解码:$O(H \times W_{lane})$,H 为图像高度,$W_{lane}$ 为每行车道像素数(~50)

实际开销:Mean-Shift ~50ms → 行级解码 <5ms(快 10 倍

洞察 3:水平+垂直亲和场 = 完整的关联信息

  • 水平亲和场(HAF):同一行内,哪些像素属于同一车道(解决车道内聚合)
  • 垂直亲和场(VAF):跨行之间,上下像素如何对应(解决车道连续性)

两者联合可唯一确定车道线实例,无需额外监督。

要记住的 3 个数字

  • 10 倍:行级解码比 Mean-Shift 快 10 倍
  • 2.3%:联合亲和场比单一亲和场提升 2.3% F1
  • 77.0%:CULane 最高精度(2021 年同期)

方法设计

网络结构

LaneAF 采用”共享 Backbone + 三个并行预测头”的设计,支持三种 Backbone:

LaneAF 管线概览
                         输入图像
                            ↓
                        [H, W, 3]
                            ↓
                ┌───────────────────────┐
                │  Backbone (stride=4)  │
                │  DLA-34 / ERFNet /    │
                │  ENet                 │
                └───────────────────────┘
                            ↓
                      [H/4, W/4, C]
                            ↓
        ┌───────────────────┼───────────────────┐
        │                   │                   │
        ↓                   ↓                   ↓
  ┌────────────┐      ┌────────────┐      ┌────────────┐
  │  hm Head   │      │  vaf Head  │      │  haf Head  │
  │  Conv 1×1  │      │  Conv 1×1  │      │  Conv 1×1  │
  └────────────┘      └────────────┘      └────────────┘
        ↓                   ↓                   ↓
   [H/4,W/4,1]         [H/4,W/4,2]         [H/4,W/4,1]
    二值分割掩码          垂直亲和场            水平亲和场
        │                   │                   │
        └───────────────────┼───────────────────┘
                            ↓
               ┌─────────────────────────┐
               │  Row-by-Row Decoding    │
               │  - 阈值过滤前景像素        │
               │  - 逐行 HAF 水平聚类      │
               │  - 跨行 VAF 垂直关联      │
               └─────────────────────────┘
                            ↓
                        车道线实例
                    [可变数量,无需预设]

Backbone 对比(代码来源:train_culane.py#L226):

Backbone 特点 预训练 推荐场景
DLA-34 深层聚合网络,精度最高 ImageNet 精度优先
ERFNet 轻量编解码结构,速度快 Cityscapes Encoder 速度优先
ENet 极轻量,参数最少 边缘部署

论文主要结果以 DLA-34 作为基准(CULane SOTA)。三个预测头的输出通道由代码中统一定义:

heads = {'hm': 1, 'vaf': 2, 'haf': 1}
# hm:二值分割(1通道)
# vaf:垂直亲和场,方向向量 (dx, dy)(2通道)
# haf:水平亲和场,单值(1通道)

整体架构

$$\text{Lane Detection} = \text{Segmentation} + \text{Affinity Fields} + \text{Row-by-Row Decoding}$$

三个预测头

预测头 输出 功能 损失权重
Segmentation $H \times W \times 1$ 车道线/背景二值分割 1.0
Horizontal Affinity (HAF) $H \times W \times 1$ 同行相邻像素是否同一车道 0.5
Vertical Affinity (VAF) $H \times W \times 1$ 跨行像素是否属于同一车道 0.5

亲和场标签生成(Encode)

训练时需要从标注的车道线生成 HAF 和 VAF 标签。以下是标签生成代码(来源:utils/affinity_fields.py):

📄 点击展开 generateAFs 代码
def generateAFs(label, viz=False):
"""
从车道线标注生成亲和场标签
参数:
label: 车道线标注图 (H x W),每个车道用不同ID标记 (1, 2, 3, ...)
返回:
VAF: 垂直亲和场 (H x W x 2)
HAF: 水平亲和场 (H x W x 2)
"""
num_lanes = np.amax(label)
VAF = np.zeros((label.shape[0], label.shape[1], 2))
HAF = np.zeros((label.shape[0], label.shape[1], 2))

# 逐车道处理
for l in range(1, num_lanes+1):
prev_cols = np.array([], dtype=np.int64)
prev_row = label.shape[0]

# 从倒数第二行到第一行逐行解析
for row in range(label.shape[0]-1, -1, -1):
cols = np.where(label[row, :] == l)[0] # 获取当前车道的列索引

# 生成水平向量(HAF)
for c in cols:
if c < np.mean(cols):
HAF[row, c, 0] = 1.0 # 指向右侧(车道中心)
elif c > np.mean(cols):
HAF[row, c, 0] = -1.0 # 指向左侧(车道中心)
else:
HAF[row, c, 0] = 0.0 # 车道中心

# 检查是否有前一行和当前行的列
if prev_cols.size == 0: # 如果没有前一行,更新后继续
prev_cols = cols
prev_row = row
continue
if cols.size == 0: # 如果当前行没有像素,跳过
continue

col = np.mean(cols) # 计算当前行车道中心

# 生成垂直向量(VAF)
for c in prev_cols:
# 计算方向向量:从前一行指向当前行
vec = np.array([col - c, row - prev_row], dtype=np.float32)
# 单位归一化
vec = vec / np.linalg.norm(vec)
VAF[prev_row, c, 0] = vec[0]
VAF[prev_row, c, 1] = vec[1]

# 更新前一行信息
prev_cols = cols
prev_row = row

return VAF, HAF

关键设计

  1. HAF 编码:车道左侧像素→右(+1.0),右侧像素→左(-1.0),中心为0 → 解码时通过符号变化检测车道边界
  2. VAF 编码:存储从当前行指向下一行对应位置的归一化方向向量 → 解码时用于预测车道延伸方向
  3. 逐行生成:从下往上处理,与解码方向一致

行级解码算法(Decode)

解码过程从图像底部向上逐行扫描,同时利用 HAF(水平聚类)和 VAF(垂直关联)。以下是解码算法代码(来源:utils/affinity_fields.py):

📄 点击展开 decodeAFs 代码
def decodeAFs(BW, VAF, HAF, fg_thresh=128, err_thresh=5, viz=False):
"""
参数:
BW: 二值分割掩码 (H x W)
VAF: 垂直亲和场 (H x W x 2),存储垂直方向向量
HAF: 水平亲和场 (H x W x 2),存储水平方向指示
fg_thresh: 前景阈值 (默认128)
err_thresh: 距离阈值 (默认5像素)
"""
output = np.zeros_like(BW, dtype=np.uint8)
lane_end_pts = [] # 跟踪每条车道的最新端点
next_lane_id = 1 # 下一个可用车道ID

# 从最后一行到第一行解码
for row in range(BW.shape[0]-1, -1, -1):
cols = np.where(BW[row, :] > fg_thresh)[0] # 获取前景列
clusters = [[]]
if cols.size > 0:
prev_col = cols[0]

# 水平解析(利用 HAF)
for col in cols:
if col - prev_col > err_thresh: # 与上一个像素距离过远
clusters.append([])
clusters[-1].append(col)
prev_col = col
continue
if HAF[row, prev_col] >= 0 and HAF[row, col] >= 0: # 继续向右
clusters[-1].append(col)
prev_col = col
continue
elif HAF[row, prev_col] >= 0 and HAF[row, col] < 0: # 找到车道中心
clusters[-1].append(col)
prev_col = col
elif HAF[row, prev_col] < 0 and HAF[row, col] >= 0: # 找到车道末端
clusters.append([])
clusters[-1].append(col)
prev_col = col
continue
elif HAF[row, prev_col] < 0 and HAF[row, col] < 0: # 继续向右
clusters[-1].append(col)
prev_col = col
continue

# 垂直解析(利用 VAF)
# 为现有车道分配簇
assigned = [False for _ in clusters]
C = np.Inf * np.ones((len(lane_end_pts), len(clusters)), dtype=np.float64)

for r, pts in enumerate(lane_end_pts): # 遍历每条活跃车道的端点
for c, cluster in enumerate(clusters):
if len(cluster) == 0:
continue
# 当前簇的均值
cluster_mean = np.array([[np.mean(cluster), row]], dtype=np.float32)
# 从车道端点获取VAF向量
vafs = np.array([VAF[int(round(x[1])), int(round(x[0])), :]
for x in pts], dtype=np.float32)
vafs = vafs / np.linalg.norm(vafs, axis=1, keepdims=True)
# 通过添加VAF预测簇中心
pred_points = pts + vafs * np.linalg.norm(pts - cluster_mean, axis=1, keepdims=True)
# 计算预测中心与实际中心之间的误差
error = np.mean(np.linalg.norm(pred_points - cluster_mean, axis=1))
C[r, c] = error

# 按误差升序分配簇到车道
row_ind, col_ind = np.unravel_index(np.argsort(C, axis=None), C.shape)
for r, c in zip(row_ind, col_ind):
if C[r, c] >= err_thresh:
break
if assigned[c]:
continue
assigned[c] = True
# 更新车道的最佳匹配
output[row, clusters[c]] = r + 1
lane_end_pts[r] = np.stack((np.array(clusters[c], dtype=np.float32),
row * np.ones_like(clusters[c])), axis=1)

# 将未分配的簇初始化为新车道
for c, cluster in enumerate(clusters):
if len(cluster) == 0:
continue
if not assigned[c]:
output[row, cluster] = next_lane_id
lane_end_pts.append(np.stack((np.array(cluster, dtype=np.float32),
row * np.ones_like(cluster)), axis=1))
next_lane_id += 1

return output

算法流程

  1. 水平聚类:根据 HAF 的符号变化判断车道边界(正值→负值 表示车道中心)
  2. 垂直关联:使用 VAF 向量预测当前簇应连接到哪条车道(通过计算预测位置与实际位置的误差)
  3. 动态车道数:未匹配的簇自动创建新车道

复杂度:$O(H \times W_{lane})$,其中 $W_{lane} \approx 50$ 像素/行 → 实际开销 <5ms

设计原理:为何采用行级扫描?

车道线的物理特性决定了行级解码的效率优势:

  1. 垂直延伸特性:车道线从车底向远处延伸,从下往上扫描是最自然的处理方式
  2. 双向亲和场协同
    • HAF 在水平方向上聚类(同一行内分离不同车道)
    • VAF 在垂直方向上关联(跨行连接同一车道)
  3. 降维优化:将二维全局聚类 $O(N^2)$ 降维为一维序列关联 $O(H \times W_{lane})$

实验与分析

主要结果(CULane 数据集)

方法 F1-Score FPS 后处理 车道数限制
SCNN 71.6% 7 分割+聚类 需预定义
PINet 74.4% 30 关键点匹配 需预定义
PolyLaneNet 75.3% 70 曲线拟合+NMS 需上限
LaneAF-DLA-34 77.0% 35 行级解码 无限制
LaneAF-ERFNet 74.4% 54 行级解码 无限制
LaneAF-ENet 72.2% 74 行级解码 无限制

关键发现

  • DLA-34 精度最高(77.0%),超越所有需要预设车道数的方法
  • ERFNet(54 FPS)和 ENet(74 FPS)在速度上更具优势,且同样无需 NMS
  • 三种 backbone 均不需要预设车道数量上限

消融研究:验证三个洞察

配置 Seg HAF VAF 解码方式 效果 验证洞察
基线 无法区分相邻实例 洞察 1
+ Affinity 行级 实例分离能力显著提升 洞察 1
+ VAF 行级 断续车道连接完整性明显改善 洞察 2
完整模型 行级 最优(DLA-34:77.0% F1) 洞察 3
完整模型 Mean-Shift 精度持平,速度显著低于行级 洞察 3

失效场景分析

论文定性分析了主要失效场景:

  • 极端遮挡:大型车辆长时间遮挡导致 VAF 向量断裂,无法完成跨行关联
  • 破损路标:断续标记使垂直亲和场出现空洞,跨行关联失败
  • 分叉拓扑:Y 型路口等违背垂直连续性假设的场景,聚类结果不稳定

性能瓶颈:提升空间在于改进遮挡和边界情况下的特征表达,而非修改解码算法本身。

工程实践

训练配置

Backbone: DLA-34 / ERFNet / ENet
Input: 640×360 (CULane)
Batch: 8
Optimizer: Adam (lr=5e-4, StepLR decay ×0.2 every 10 epochs)
Augmentation: 水平翻转、透视变换、色彩抖动
Training Time: ~24h (DLA-34, V100)

复现要点

  1. 亲和场标签生成:同实例相邻像素标记为 1,不同实例标记为 0。边界像素需谨慎处理,避免跨车道关联。必须先可视化标签

  2. 损失平衡:车道线像素稀疏(<5%),对分割损失使用 Focal Loss 或加权。亲和场损失权重 0.5 最优。

  3. 行级解码调试:边界条件(第一行初始化、最后一行终止)易出错。建议单独测试解码模块,用简单 case 验证逻辑。

  4. 数据增强:透视变换至关重要(模拟视角变化),但避免过度旋转(破坏垂直先验)。

性能优化方向

精度提升(+1~3% F1):

  • 多尺度融合:处理远近车道线
  • Transformer:长距离像素关联
  • 时序融合:利用连续帧稳定预测

速度优化

  • 单一亲和场:仅使用 VAF 可降低计算量,以一定精度损失换取更高 FPS
  • 轻量 Backbone:替换为 MobileNet 等轻量网络可加速推理
  • 低分辨率输入:降低输入尺寸可线性加速,代价是小目标检测能力下降

研究启示

可迁移的思想

  1. 任务先验 > 模型复杂度
    深入理解任务特性(车道线垂直连续性),设计针对性算法,比盲目堆叠层数更有效。将 $O(N^2)$ 降至 $O(N)$ 的关键是发现”序列结构”。

  2. 二值关系 > 高维嵌入
    直接建模像素间的关联关系(0/1),比学习高维嵌入空间更稳定、更可解释。类似思想可用于实例分割、目标跟踪等任务。

  3. 可解释性 = 工程价值
    亲和场可直接可视化,便于定位失败原因。在工业部署中,可调试性与精度同样重要。

  4. 性能瓶颈的定量分析
    通过分析失效模式(遮挡、破损路标、分叉场景),精准定位改进方向,比盲目调参高效。

方法局限

  • 强依赖垂直先验:Y 型路口、分叉场景失效
  • 遮挡鲁棒性不足:长时间遮挡导致 VAF 断裂
  • 无时序信息:单帧预测,闪烁问题明显

技术影响

  • 代码开源:GitHub 获得广泛关注,便于学术研究和工业应用
  • 方法创新:为像素级关联方法提供了新的研究思路
  • 工业应用:在自动驾驶感知模块中部署,可解释性强便于故障排查
  • 性能基准:CULane 77.0% F1-Score 成为 2021 年同期最高水平