一种基于Resnet的图像巡线丢线检测算法

摘要

Resnet是一种常见的神经网络结构,常被使用于图像分类和特征提取。前人研究将其用于提取图片黑线特征点提取,输出特征点坐标,在具有神经网络加速硬件的环境实现高效的寻迹算法。而在一些场景中,图片中可能不存在黑线,而网络仍然有特征点的输出,这违背特征点提取的初衷。使用其他计算机视觉的方法检测特征点会失去Resnet高效的优点。本研究解决这一困境,结合Resnet的多个用途,实现基于Resnet的图像寻迹丢线检测算法。

前人的研究

地平线社区 NodeHub 提供了一种已有的基于Resnet18的寻迹算法。

算法对输入的摄像头图像进行裁剪处理,保留其下半部分高度为224像素,宽度不变,并将其压缩为224x224的矩形;此后,对裁剪后的图像进行标准化处理,将其作为训练数据集。选取图像中黑线上的某一点作为特征点,并将其归一化处理后作为数据集的标签。为了适应输出的格式,修改ResNet18模型的全连接层,将其输出维度设置为2,分别代表归一化后的x和y坐标。

我们训练并验证了该算法的性能。并在地平线开发版上移植成功。结果表明,该算法在简单的寻迹任务中表现出了良好的准确性和稳定性,且具有优秀的性能,能够有效地识别并跟踪行驶车辆的路径。

问题

在第19届智能车竞赛地平线组中,除了寻迹之外,还需要进行避障任务。然而,在一些特别的位置,对锥桶进行避障之后会出现丢线情况。然而,Resnet作为神经网络,只要有图片输入,就会不间断地有提取的特征点的输出。这显然不利于我们做丢线检测。一种替代的方法是使用传统计算机视觉,提取特征点周围像素并用阈值进行判断。这又有悖于对寻迹算法高效的要求。最终我们决定,将丢线的判断也交给Resnet处理。

改进

我们选择在原有表示特征点位置的输出x,y的基础上增加p,表示特征点的置信度。若图像识别到的x,y越接近真实特征点,则p越接近1;反之,若图像不存在特征点,即丢线,则p越接近0。

数据采集

与原有数据相比,增加丢线检测后数据集需要增加一部分没有黑线的数据用于训练和验证。

使用OriginCar Ros2 图像捕获工具进行采集。

数据标记

输入图像,对图像进行裁切和拉伸,使用鼠标标记特征点,并将标记结果以<file_name>_{x}_{y}_{p}.<extention_name>的格式保存到文件名中。在标记过程中,存在黑线的p标注为1,不存在黑线的p标注为0。

示例代码如下。

import os
import cv2

# 定义全局变量
x, y = None, None
clicked = False

# 鼠标事件回调函数
def draw_circle(event, _x, _y, flags, param):
    global x, y, clicked

    if event == cv2.EVENT_LBUTTONDOWN:
        # 保存最新的点击位置
        x, y = _x, _y
        clicked = True
        # 绘制红色圆圈
        img = param.copy()
        cv2.circle(img, (x, y), 5, (0, 0, 255),-1)
        cv2.imshow('image', img)

def annotate_images(input_folder, output_folder):
    # 创建输出文件夹
    if not os.path.exists(output_folder):
        os.makedirs(output_folder)

    # 获取输入文件夹中的所有图像文件
    image_files = [f for f in os.listdir(input_folder) if f.endswith(('.jpg', '.jpeg', '.png'))]

    for image_file in image_files:
        # 读取图像
        img = cv2.imread(os.path.join(input_folder, image_file))

        # 裁剪底部 224 像素,保持宽度不变
        cropped_img = img[-224:, :]

        # 压缩为 224x224 的图像
        resized_img = cv2.resize(cropped_img, (224, 224))

        # 创建窗口并绑定鼠标事件回调函数
        cv2.namedWindow('image')
        cv2.setMouseCallback('image', draw_circle, resized_img)

        # 展示图像
        cv2.imshow('image', resized_img)

        # 等待用户输入
        while True:
            key = cv2.waitKey(1)
  
            p = 0

            # 如果用户按下了回车键(确认)
            if key == 13:
                global clicked
                if clicked:
                    p = 1
                clicked = False  # 重置 clicked
                break  # 退出循环

            # 如果用户按下了 ESC 键
            elif key == 27:
                break  # 退出循环

            # 如果用户按下了 p 键
            elif key == ord('p'):
                cv2.destroyAllWindows()
                return
        print(f"Saving annotated image: {image_file} ({x}, {y}, {p})")
        # 保存带有标注信息的图像
        output_filename = f"{os.path.splitext(image_file)[0]}_{x}_{y}_{p}.jpg"
        cv2.imwrite(os.path.join(output_folder, output_filename), resized_img)

    # 关闭窗口
    cv2.destroyAllWindows()

数据增强

通过简单变换对数据进行增强,增大数据集并避免模型过拟合。

修改全连接层

输出为[x,y,p],因而将全连接层的输出改为3。

num_ftrs = model.fc.in_features
    model.fc = nn.Linear(num_ftrs, 3)

设计损失函数

设计损失函数是本项目最重要的环节。若使用默认的损失函数,则当p真实值为0时会影响x与y的损失函数计算。很显然,我们希望真实值p=0时预测的p尽量接近0;真实值p=1时x,y与真实值接近,且p与真实值接近。

给定模型的输出 $ \text{outputs} $ 和标签 $ \text{labels} $,其中标签包含三个值:$ x $,$ y $,$ p $,表示位置和置信度。

定义两个集合 $ S_0 $ 和 $ S_1 $,分别包含 $ p=0 $ 和 $ p=1 $ 的样本。

对于 $ p=0 $ 的样本:

$$ \text{loss}_{p=0} = \frac{1}{|S_0|} \sum_{i \in S_0} (o_{i,2} - l_{i,2})^2 $$

其中,$ o_{i,2} $ 表示输出 $ \text{outputs}_i $ 中的置信度值,$ l_{i,2} $ 表示标签 $ \text{labels}_i $ 中的置信度值。

对于 $ p=1 $ 的样本:

$$ \text{loss}_{p=1} = \frac{1}{|S_1|} \sum_{i \in S_1} \left( (o_{i,0} - l_{i,0})^2 + (o_{i,1} - l_{i,1})^2 \right) \left( (o_{i,2} - l_{i,2})^2 + 1 \right) $$

最终的损失函数为两者之和:

$$ \text{loss} = \text{loss}_{p=0} + \text{loss}_{p=1} $$

其中 $ |S_0| $ 和 $ |S_1| $ 分别是 $ p=0 $ 和 $ p=1 $ 的样本数量。

代码如下。

def my_loss(outputs, labels):
    p = labels[:, 2]  # 获取标签中的 p 值
    loss = torch.zeros_like(p, dtype=torch.float32, requires_grad=True).to(outputs.device)  # 初始化损失
    loss_p0 = torch.zeros_like(p, dtype=torch.float32, requires_grad=False).to(outputs.device)
    loss_p1 = torch.zeros_like(p, dtype=torch.float32, requires_grad=False).to(outputs.device)
    mask_p_0 = (p == 0)  # 选择标签中 p 为 0 的样本
    mask_p_1 = (p == 1)  # 选择标签中 p 为 1 的样本
  
    # 计算 p=0 时的损失
    if mask_p_0.any():
        diff_p_0 = outputs[mask_p_0, 2] - labels[mask_p_0, 2]  # 计算 outputs 和 labels 中 p=0 时的差值
        loss_p0[mask_p_0] = torch.mean(diff_p_0 ** 2)  # 计算损失为差值的平方的均值
  
    # 计算 p=1 时的损失
    if mask_p_1.any():
        diff_x_p_1 = outputs[mask_p_1, 0] - labels[mask_p_1, 0]  # 计算 outputs 和 labels 中 p=1 时 x 的差值
        diff_y_p_1 = outputs[mask_p_1, 1] - labels[mask_p_1, 1]  # 计算 outputs 和 labels 中 p=1 时 y 的差值
        diff_p_1 = outputs[mask_p_1, 2] - labels[mask_p_1, 2]  # 计算 outputs 和 labels 中 p=1 时的 p 的差值
        loss_p1[mask_p_1] = torch.mean((diff_x_p_1 ** 2 + diff_y_p_1 ** 2) * (diff_p_1 ** 2 + 1)) # 计算损失
  
    loss = loss_p0+loss_p1
    return torch.mean(loss)

评估

经有限次训练后部分样本识别结果如下:

部分样本识别结果

特征点识别均方误差:

MSE: 0.09188192337751389

设置p阈值为0.5,即p>0.5时认定为为成功巡线,反之为丢线,丢线判断评估指标:

Confidence Loss: 0.033583346754312515

Accuracy: 0.9830729166666666

AUC-ROC: 0.9963484611371936

混淆矩阵

PR曲线

富婆饿饿饭饭