【感知·车道线检测】UFLDv2车道线检测与车道偏离预警(LDWS)实战

你好!我是探物 AI,多模态感知算法工程师,不定期分享自己学习的环境感知算法。

这篇文章我想和你详细聊聊UFLDv2车道线检测算法,以及如何基于检测结果搭建一个实用的车道偏离预警系统。这是我做ADAS项目时花时间最多的模块,踩了不少坑,希望这些经验能帮到你。

一、为什么选择UFLDv2?

在车道线检测领域,主流方案大致分为两类:

方案 代表方法 速度 精度 特点
语义分割 SCNN, ERFNet 像素级分割,计算量大
行/列锚点回归 UFLD, UFLDv2 较高 基于锚点的位置回归,极快

UFLDv2(Ultra Fast Lane Detection v2)的核心思想是:不逐像素分割车道线,而是将车道线检测转化为行/列锚点上的位置分类问题。这种设计让它的推理速度极快,非常适合ADAS这种对实时性要求很高的场景。

UFLDv2 vs UFLD v1 的主要区别

  • v1只有行锚点(row anchor),v2同时使用行锚点和列锚点(col anchor)
  • v2增加了车道线存在性判断(exist_row / exist_col),更鲁棒
  • v2输出4个分支:loc_rowloc_colexist_rowexist_col
  • v2支持更多数据集:CULane、TuSimple、CurveLanes

二、UFLDv2算法原理

2.1 核心思想

传统语义分割方法需要输出一张与输入同尺寸的分割图,计算量巨大。UFLDv2换了个思路:

  1. 在图像的预设行位置 上,预测车道线的水平坐标

  2. 在图像的预设列位置 上,预测车道线的垂直坐标

  3. 将连续坐标离散化为分类问题(griding_num个类别)

    输入图像 (H × W)

    ResNet Backbone (提取特征)

    1×1 Conv (降维到8通道)

    Flatten → MLP (全连接层)

    ┌──────────┬──────────┬──────────┬──────────┐
    │ loc_row │ loc_col │exist_row │exist_col │
    │ 行位置预测 │ 列位置预测 │ 行存在性 │ 列存在性 │
    └──────────┴──────────┴──────────┴──────────┘

2.2 模型配置

不同数据集的配置参数不同,项目中CULane的配置如下:

python 复制代码
class ModelConfig():
    def init_culane_config(self):
        self.img_w = 1600          # 模型输入宽度
        self.img_h = 320           # 模型输入高度
        self.griding_num = 200     # 横向离散化数量
        self.crop_ratio = 0.6      # 裁剪比例(只取图像下半部分)
        self.row_anchor = np.linspace(0.42, 1, 72)  # 72个行锚点
        self.col_anchor = np.linspace(0, 1, 81)     # 81个列锚点
        self.num_lanes = 4         # 检测4条车道线

关键参数解释

  • griding_num=200:将水平方向离散化为200个格子,每个格子对应一个分类类别
  • row_anchor:在图像纵向上均匀采样72个位置,在这些位置上预测车道线的横坐标
  • crop_ratio=0.6:只取图像下方60%的区域(车道线主要在下半部分)

2.3 网络结构

python 复制代码
class parsingNet(torch.nn.Module):
    def __init__(self, backbone='18', num_grid_row=200, num_cls_row=72, 
                 num_grid_col=100, num_cls_col=81, num_lanes=4, ...):
        super().__init__()
        
        # ResNet骨干网络
        self.model = resnet(backbone, pretrained=pretrained)
        
        # 1×1卷积降维:512→8通道(ResNet18)
        self.pool = torch.nn.Conv2d(512, 8, 1)
        
        # 全连接分类头
        self.input_dim = input_height // 32 * input_width // 32 * 8  # 10×50×8=4000
        mlp_mid_dim = 2048
        self.cls = torch.nn.Sequential(
            torch.nn.LayerNorm(self.input_dim),
            torch.nn.Linear(self.input_dim, mlp_mid_dim),
            torch.nn.ReLU(),
            torch.nn.Linear(mlp_mid_dim, total_dim),  # 4个分支的输出拼接
        )

    def forward(self, x):
        x2, x3, fea = self.model(x)       # ResNet提取特征
        fea = self.pool(fea)                # 1×1卷积降维
        fea = fea.view(-1, self.input_dim)  # 展平
        out = self.cls(fea)                 # MLP预测
        
        # 拆分为4个输出
        pred_dict = {
            'loc_row':   out[:, :dim1].view(-1, num_grid_row, num_cls_row, num_lanes),
            'loc_col':   out[:, dim1:dim1+dim2].view(-1, num_grid_col, num_cls_col, num_lanes),
            'exist_row': out[:, dim1+dim2:dim1+dim2+dim3].view(-1, 2, num_cls_row, num_lanes),
            'exist_col': out[:, -dim4:].view(-1, 2, num_cls_col, num_lanes),
        }
        return pred_dict

输出维度解析(以CULane为例):

输出 形状 含义
loc_row (B, 200, 72, 4) 72个行位置上,4条车道线的横向位置(200类分类)
loc_col (B, 100, 81, 4) 81个列位置上,4条车道线的纵向位置(100类分类)
exist_row (B, 2, 72, 4) 72个行位置上,4条车道线是否存在(2类分类)
exist_col (B, 2, 81, 4) 81个列位置上,4条车道线是否存在(2类分类)

三、模型训练

3.1 训练配置

项目中的CULane训练配置(culane_res18.py):

python 复制代码
dataset = 'CULane'
epoch = 50
batch_size = 32
optimizer = 'SGD'
learning_rate = 0.05
weight_decay = 0.0001
momentum = 0.9
scheduler = 'multi'
steps = [25, 38]         # 学习率衰减的epoch
gamma = 0.1              # 衰减系数
warmup = 'linear'
warmup_iters = 695
backbone = '18'           # ResNet18
num_lanes = 4
griding_num = 200
num_row = 72
num_col = 81
train_width = 1600
train_height = 320
use_aux = False           # 不使用辅助分割头
fc_norm = True            # 全连接层前加LayerNorm

3.2 训练流程

bash 复制代码
# 1. 克隆UFLDv2官方仓库
git clone https://github.com/cfzd/Ultra-Fast-Lane-Detection-v2.git

# 2. 准备CULane数据集
# 下载后放到data/CULane目录

# 3. 修改配置文件
# 编辑configs/culane_res18.py,设置data_root路径

# 4. 开始训练
python train.py configs/culane_res18.py

3.3 训练技巧

  1. Backbone选择:ResNet18足够用,速度快;需要更高精度可以换ResNet34/50
  2. crop_ratio:CULane用0.6,TuSimple用0.8,根据数据集调整
  3. use_aux:训练时可以开启辅助分割头帮助收敛,推理时关闭
  4. 学习率策略:SGD + MultiStepLR,在epoch 25和38处衰减

四、模型部署:PyTorch → ONNX

4.1 转换脚本

项目提供了完整的转换脚本 convertPytorchToONNX.py

python 复制代码
def convert_model(model_path, model_type=LaneModelType.UFLDV2_CULANE):
    # 加载配置
    cfg = LaneV2Config("./configs/" + Path(model_path).stem + ".py")
    
    # 构建模型
    net = model_tusimple.get_model(cfg)
    
    # 加载权重(处理多GPU训练的module.前缀)
    state_dict = torch.load(model_path, map_location='cpu')['model']
    compatible_state_dict = {}
    for k, v in state_dict.items():
        if 'module.' in k:
            compatible_state_dict[k[7:]] = v
        else:
            compatible_state_dict[k] = v
    net.load_state_dict(compatible_state_dict, strict=False)
    
    # 导出ONNX
    img = torch.zeros(1, 3, cfg.train_height, cfg.train_width).to('cuda')
    torch.onnx.export(net, img, onnx_file_path, verbose=True)
    
    # 验证
    model = onnx.load(onnx_file_path)
    onnx.checker.check_model(model)

4.2 执行转换

bash 复制代码
cd TrafficLaneDetector
python convertPytorchToONNX.py

转换完成后会生成 ufldv2_culane_res18_320x1600.onnx 文件。

4.3 FP16量化(可选)

如果需要进一步加速,可以做FP16量化:

bash 复制代码
python onnxQuantization.py -i ./TrafficLaneDetector/models/ufldv2_culane_res18_320x1600.onnx

五、车道线检测推理

5.1 输入预处理

python 复制代码
def __prepare_input(self, image):
    img = cv2.cvtColor(image, cv2.COLOR_BGR2RGB)
    
    # 按crop_ratio裁剪下半部分
    new_size = (self.input_width, int(self.input_height / self.cfg.crop_ratio))
    img_input = cv2.resize(img, new_size).astype(np.float32)
    img_input = img_input[-self.input_height:, :, :]  # 取底部
    
    # ImageNet标准化
    mean = [0.485, 0.456, 0.406]
    std  = [0.229, 0.224, 0.225]
    img_input = ((img_input / 255.0 - mean) / std)
    
    # HWC → NCHW
    img_input = img_input.transpose(2, 0, 1)[np.newaxis, :, :, :]
    return img_input.astype(self.input_types)

关键点crop_ratio=0.6 意味着只取图像下方60%送入模型,因为车道线主要出现在图像下半部分。

5.2 输出后处理

这是最核心的部分,如下图的解释:将模型输出转换为车道线坐标点:

python 复制代码
def __process_output(self, output, cfg, local_width=1):
    output = {
        "loc_row":   output[0],  # 行位置预测
        "loc_col":   output[1],  # 列位置预测
        "exist_row": output[2],  # 行存在性
        "exist_col": output[3],  # 列存在性
    }
    
    # 获取每个位置的最大概率索引
    max_indices_row = output['loc_row'].argmax(1)
    valid_row = output['exist_row'].argmax(1)  # 存在性判断
    max_indices_col = output['loc_col'].argmax(1)
    valid_col = output['exist_col'].argmax(1)
    
    lanes_points = {"left-side": [], "left-ego": [], "right-ego": [], "right-side": []}
    lanes_detected = {"left-side": False, "left-ego": False, "right-ego": False, "right-side": False}
    
    # 处理行锚点(检测水平车道线段)
    row_lane_idx = [1, 2]  # 左车道线和右车道线
    for i in row_lane_idx:
        tmp = []
        # 存在性判断:超过一半的行位置检测到才算有效
        if valid_row[0, :, i].sum() > num_cls_row / 2:
            for k in range(valid_row.shape[1]):
                if valid_row[0, k, i]:
                    # softmax + 加权求和,得到亚像素精度的坐标
                    all_ind = list(range(max(0, max_indices_row[0,k,i] - local_width),
                                         min(num_grid_row-1, max_indices_row[0,k,i] + local_width) + 1))
                    out_tmp = (_softmax(output['loc_row'][0, all_ind, k, i]) * 
                              list(map(float, all_ind))).sum() + 0.5
                    out_tmp = out_tmp / (num_grid_row - 1) * original_image_width
                    tmp.append((int(out_tmp), int(cfg.row_anchor[k] * original_image_height)))
        
        if i == 1:
            lanes_points["left-ego"].extend(tmp)
            if len(tmp) > 2: lanes_detected["left-ego"] = True
        else:
            lanes_points["right-ego"].extend(tmp)
            if len(tmp) > 2: lanes_detected["right-ego"] = True
    
    # 列锚点处理类似...
    
    return np.array(list(lanes_points.values()), dtype="object"), list(lanes_detected.values())

后处理的关键技巧

  1. Softmax加权求和 :不是直接取argmax,而是在最大值附近取local_width个值做softmax加权,这样能得到亚像素精度的坐标
  2. 存在性判断exist_row/col判断每个位置是否有车道线,超过一半位置检测到才认为该车道线有效
  3. 4条车道线left-sideleft-egoright-egoright-side,其中left-egoright-ego是本车道左右线

5.3 车道区域提取

检测到车道线后,需要提取本车道的区域多边形:

python 复制代码
def __update_lanes_area(self, lanes_points, img_height):
    if self.lane_info._area_status:
        index = len(lanes_points) // 2
        left_lanes_points = lanes_points[index - 1]   # left-ego
        right_lanes_points = lanes_points[index]       # right-ego
        
        # 可选:多项式拟合平滑车道线
        if self.adjust_lanes:
            left_lanes_points, right_lanes_points = self.__adjust_lanes_points(...)
        
        # 拼接成封闭多边形:左线点 + 右线点(翻转)
        self.lane_info._area_points = np.vstack((
            left_lanes_points, 
            np.flipud(right_lanes_points)
        ))

多项式拟合平滑

python 复制代码
@staticmethod
def __adjust_lanes_points(left_lanes_points, right_lanes_points, image_height):
    # 二次多项式拟合
    leftx, lefty = list(zip(*left_lanes_points))
    left_fit = np.polyfit(lefty, leftx, 2)
    
    rightx, righty = list(zip(*right_lanes_points))
    right_fit = np.polyfit(righty, rightx, 2)
    
    # 生成拟合后的点
    both_fity = np.linspace(miny, maxy, image_height)
    left_fitx = left_fit[0]*both_fity**2 + left_fit[1]*both_fity + left_fit[2]
    right_fitx = right_fit[0]*both_fity**2 + right_fit[1]*both_fity + right_fit[2]
    
    return [(int(l), int(y)) for l, y in zip(left_fitx, both_fity)], \
           [(int(r), int(y)) for r, y in zip(right_fitx, both_fity)]

六、车道偏离预警(LDWS)设计

6.1 偏离判断原理

LDWS的核心是计算车辆在车道内的横向偏移量,流程如下:

6.2 透视变换

将前视图转为鸟瞰图,才能准确计算偏移量:

python 复制代码
class PerspectiveTransformation(object):
    def __init__(self, img_size=(1280, 720)):
        # 源点:前视图中的梯形区域
        self.src = np.float32([
            (img_size[0]*0.3,  img_size[1]*0.7),   # 左上
            (img_size[0]*0.2,  img_size[1]),         # 左下
            (img_size[0]*0.95, img_size[1]),         # 右下
            (img_size[0]*0.8,  img_size[1]*0.7),    # 右上
        ])
        # 目标点:鸟瞰图中的矩形区域
        offset_x = img_size[0] / 4
        self.dst = np.float32([
            (offset_x, 0),
            (offset_x, img_size[1]),
            (img_size[0] - offset_x, img_size[1]),
            (img_size[0] - offset_x, 0),
        ])
        # 计算变换矩阵
        self.M = cv2.getPerspectiveTransform(self.src, self.dst)
        self.M_inv = cv2.getPerspectiveTransform(self.dst, self.src)
    
    def transformToBirdView(self, img):
        return cv2.warpPerspective(img, self.M, self.img_size)

6.3 偏移量计算

在鸟瞰图上计算偏移量:

python 复制代码
def calcCurveAndOffset(self, img, left_lanes, right_lanes):
    if len(left_lanes) and len(right_lanes):
        # 多项式拟合
        left_fit = np.polyfit(left_lanes[:, 1], left_lanes[:, 0], 2)
        right_fit = np.polyfit(right_lanes[:, 1], right_lanes[:, 0], 2)
        
        # 像素到米的转换
        ym_per_pix = 30 / 720    # 纵向:30米对应720像素
        xm_per_pix = 3.7 / 700   # 横向:3.7米(标准车道宽)对应700像素
        
        # 计算车道中心
        leftx = left_fit[0]*ploty**2 + left_fit[1]*ploty + left_fit[2]
        rightx = right_fit[0]*ploty**2 + right_fit[1]*ploty + right_fit[2]
        
        lane_width = abs(leftx[719] - rightx[719])
        lane_xm_per_pix = 3.7 / lane_width
        
        veh_pos = (leftx[719] + rightx[719]) / 2.0  # 车道中心
        cen_pos = img.shape[1] / 2.0                  # 车辆中心(图像中心)
        
        # 偏移量(米),正=右偏,负=左偏
        distance_from_center = (veh_pos - cen_pos) * lane_xm_per_pix
        return distance_from_center

6.4 偏离状态判断

python 复制代码
class TaskConditions(object):
    def __init__(self):
        self.vehicle_offset_record = LimitedList(5)  # 滑动窗口5帧
    
    def UpdateOffsetStatus(self, vehicle_offset, offset_thres=0.65):
        if vehicle_offset is not None:
            self.vehicle_offset_record.append(vehicle_offset)
            
            if self.vehicle_offset_record.full():
                avg_offset = np.median(self.vehicle_offset_record)
                
                if abs(avg_offset) > offset_thres:
                    if avg_offset > 0:
                        self.offset_msg = OffsetType.RIGHT   # "Please Keep Right"
                    else:
                        self.offset_msg = OffsetType.LEFT    # "Please Keep Left"
                else:
                    self.offset_msg = OffsetType.CENTER      # "Good Lane Keeping"

偏离阈值说明

  • offset_thres=0.65:偏移量超过0.65米触发预警
  • 使用中位数滤波避免抖动
  • 滑动窗口5帧(30fps下约0.17秒延迟)

七、可视化效果

7.1 车道线绘制

python 复制代码
def DrawDetectedOnFrame(self, image, type=OffsetType.UNKNOWN, alpha=0.3):
    overlay = image.copy()
    for lane_num, lane_points in enumerate(self.lane_info.lanes_points):
        # 偏离时对应车道线变红
        if lane_num == 1 and type == OffsetType.RIGHT:
            color = (0, 0, 255)
        elif lane_num == 2 and type == OffsetType.LEFT:
            color = (0, 0, 255)
        else:
            color = lane_colors[lane_num]
        
        for lane_point in lane_points:
            cv2.circle(overlay, lane_point, 3, color, thickness=-1)
    
    image[:] = cv2.addWeighted(overlay, alpha, image, 1 - alpha, 0)

7.2 车道区域填充

python 复制代码
def DrawAreaOnFrame(self, image, color=(255, 191, 0), alpha=0.85):
    if self.lane_info.area_status:
        lane_segment_img = image.copy()
        cv2.fillPoly(lane_segment_img, pts=[self.lane_info.area_points], color=color)
        image[:] = cv2.addWeighted(image, alpha, lane_segment_img, 1 - alpha, 0)

八、性能指标与优化方向

8.1 性能参考

指标 数值 说明
推理速度(ONNX) ~15ms/帧 ResNet18 + ONNX Runtime
推理速度(TensorRT FP16) ~5ms/帧 需要TensorRT环境
CULane F1 ~75% ResNet18
模型大小 ~16MB ONNX格式

8.2 优化方向

  1. 换更轻量的Backbone:ResNet18 → MobileNetV3,速度更快
  2. TensorRT部署:FP16推理可提速3倍
  3. 动态透视变换:根据车道线位置自适应调整变换区域(项目已实现)
  4. 夜间增强:加入低光照增强预处理
  5. 时序平滑:利用前后帧的车道线一致性做时序滤波

九、完整使用流程

python 复制代码
from TrafficLaneDetector import UltrafastLaneDetectorV2
from TrafficLaneDetector.ufldDetector.perspectiveTransformation import PerspectiveTransformation
from taskConditions import TaskConditions

# 1. 初始化车道检测模型
lane_config = {
    "model_path": "./TrafficLaneDetector/models/ufldv2_culane_res18_320x1600.onnx",
    "model_type": LaneModelType.UFLDV2_CULANE
}
UltrafastLaneDetectorV2.set_defaults(lane_config)
laneDetector = UltrafastLaneDetectorV2(logger=LOGGER)

# 2. 初始化透视变换
transformView = PerspectiveTransformation((width, height))

# 3. 初始化状态分析
analyzeMsg = TaskConditions()

# 4. 主循环
while cap.isOpened():
    ret, frame = cap.read()
    if ret:
        # 车道线检测
        laneDetector.DetectFrame(frame)
        
        # 透视变换
        birdview_show = transformView.transformToBirdView(frame)
        birdview_lanes_points = [transformView.transformToBirdViewPoints(lp) 
                                  for lp in laneDetector.lane_info.lanes_points]
        
        # 计算偏移量
        (_, _), vehicle_offset = transformView.calcCurveAndOffset(
            birdview_show, *birdview_lanes_points[1:3])
        
        # 更新偏离状态
        analyzeMsg.UpdateOffsetStatus(vehicle_offset)
        
        # 可视化
        laneDetector.DrawDetectedOnFrame(frame, analyzeMsg.offset_msg)
        laneDetector.DrawAreaOnFrame(frame)

十、总结

UFLDv2是一个非常实用的车道线检测算法,它的行/列锚点设计让速度和精度达到了很好的平衡。基于检测结果,通过透视变换和偏移量计算,就能搭建一个实用的LDWS系统。

做这个模块时我最大的体会是:后处理和状态判断比模型本身更重要。模型给出的是每帧的原始检测结果,如何把这些结果变成稳定可靠的预警信号,才是真正考验工程能力的地方。

希望这篇文章对你有帮助!如果你在做车道线检测时遇到什么问题,欢迎交流。

相关推荐
Swilderrr2 小时前
学术研读报告:MEM1面向长视距智能体的记忆 - 推理协同框架
人工智能
菜鸟丁小真2 小时前
LeetCode hot100 -54.螺旋矩阵
算法·leetcode·矩阵·知识点总结
aLTttY2 小时前
Spring Boot整合AI大模型实现智能问答系统实战
人工智能·spring boot·后端
weixin_468466852 小时前
排列组合算法之隔板问题与错排公式
c++·算法·数学建模·排列组合·竞赛·错排·隔板
wsoz2 小时前
Leetcode链表-day9
c++·算法·leetcode·链表
easy_coder2 小时前
《工程化视角下的Prompt设计与迭代:云诊断与CICD变更风控中的实践》
人工智能·云计算·prompt
AI木马人2 小时前
7.【RAG系统完整实战】如何让AI读取你的私有数据?(从原理到落地)
人工智能·深度学习·神经网络·自然语言处理
精益数智工坊2 小时前
红牌作战是什么?红牌作战的实施步骤与核心要点
大数据·运维·前端·人工智能·精益工程
Lumos_7772 小时前
Linux -- 系统调用
linux·运维·算法