跳到主要内容
极客日志极客日志面向AI+效率的开发者社区
首页博客我的书AI学习GitHub 精选镜像AI 生图工具UI配色美学关于
搜索内容 / 工具 / 仓库 / 镜像...⌘K搜索
注册
博客列表
PythonAI算法

智能车竞赛惯导与视觉避障实战经验分享

针对全国大学生智能车竞赛中的惯导与视觉避障难题,分享了网络优化、上位机辅助处理、定点扫码及终点校准的实战方案。通过调整路由器信道与有线连接解决延迟,利用深度相机提升扫码成功率。提出基于逆透视变换的 P 点校准方法,并提供了 STM32 源码调优建议。最后附赠数据集自动标注、清洗及增强脚本,助力同类项目开发。

咸鱼开飞机发布于 2026/4/7更新于 2026/9/1169 浏览
智能车竞赛惯导与视觉避障实战经验分享

前言

在全国大学生智能车竞赛(智慧医疗机器人创意赛)中,团队在备赛过程中积累了一些关于惯导、视觉避障及系统优化的经验。为了提升比赛成绩,我们针对网络延迟、上位机辅助、扫码节点及终点校准等关键环节进行了深度调试。以下分享部分核心思路与代码实现。

网络优化

网络延迟是比赛中常见的问题,直接影响上位机与小车通信的稳定性。第一年参赛时曾遇到严重的延迟问题,第二年我们重点优化了网络环境。

建议优先使用有线连接上位机与路由器,避免使用板载无线网卡。在信道选择上,尽量避开拥堵频段。例如,在某些区域赛中,165 信道相对空闲,能显著降低干扰。若现场网络环境复杂,可考虑本地部署轻量级模型作为备用方案,减少对云端 API 的依赖。

文章配图 图 1 路由器型号参考

文章配图 图 2 设备外观

上位机辅助处理

在上位机端,可以通过独立的 Python 脚本接收 YOLO 检测结果,并使用 tkinter 绘制障碍物位置,帮助机师快速判断环境。虽然最初尝试过小地图模式,但直接叠加红线提示效果更佳。

import tkinter as tk
from rclpy.node import Node
from rclpy.qos import QoSProfile, ReliabilityPolicy
from sensor_msgs.msg import Image
from std_msgs.msg import String, Int32
from nav_msgs.msg import Odometry
from origincar_msg.msg import Sign
from cv_bridge import CvBridge
import cv2
import numpy as np

class LLM2Origincar:
    def __init__(self, host, port):
        self.ros = None
        self.host = host
        self.port = port
        self.roadblock_list = []
        self.end_list = []
        self.init_ros()
        self.init_topic()
        self.init_thread()
        self.keep()

    def init_topic(self):
        # ... (ROS topic initialization logic)
        self.yolo_sub = Topic(self.ros, '/hobot_dnn_detection', 'ai_msgs/msg/PerceptionTargets', latch=True)
        self.yolo_sub.subscribe(self.yolo_sub_callback)

    def yolo_sub_callback(self, msg):
        self.roadblock_list.clear()
        self.end_list.clear()
        for target in msg['targets']:
            if target['type'] == 'roadblock':
                rect = target['rois'][0]['rect']
                self.roadblock_list.append({
                    'x': rect['x_offset'],
                    'w': rect['width'],
                    'b': rect['y_offset'] + rect['height'],
                })
            elif target['type'] == 'end':
                rect = target['rois'][0]['rect']
                self.end_list.append({
                    'x': rect['x_offset'],
                    'y': rect['y_offset'],
                    'w': rect['width'],
                    'b': rect['y_offset'] + rect['height'],
                    'c': target['rois'][0]['confidence'],
                })

    def keep(self):
        try:
            while True:
                canvas.delete("all")
                canvas.create_line(141, 0, 141, 680, fill="red", width=1)
                canvas.create_line(689, 0, 689, 680, fill="red", width=1)
                if self.roadblock_list:
                    for obst in self.roadblock_list:
                        b = int(obst['b'] * 1.42) # 680 / 480
                        canvas.create_line(
                            int(obst['x'] * 1.41),
                            b,
                            int((obst['x'] + obst['w']) * 1.41),
                            b,
                            fill="red", width=2
                        )
                if self.end_list:
                    for end in self.end_list:
                        x1 = int(end['x'] * 1.41)
                        y1 = int(end['y'] * 1.41)
                        x2 = int((end['x'] + end['w']) * 1.41)
                        y2 = int(end['b'] * 1.42)
                        canvas.create_line(x1, y2, x2, y2, fill="blue", width=1)
                        canvas.create_text(int((x1+x2)/2), (y1-20) if (y1-20) > 0 else 0, text=f"conf:{end['c']:.2f}", fill='cyan')

此外,通过键盘按键可以辅助任务切换和调用 API,例如按下特定键触发返回信号或退出遥操作。

def keyboard_thread(self):
    while True:
        sleep(0.05)
        if keyboard.is_pressed('b') or keyboard.is_pressed('B'):
            self.sign4return_pub.publish(self.sign4return_data)
            sleep(0.5)
        if keyboard.is_pressed('r') or keyboard.is_pressed('R'):
            self.sign4return_data['data'] = 5
            self.sign4return_pub.publish(self.sign4return_data)
            self.sign4return_data['data'] = 0
            sleep(0.5)
        if keyboard.is_pressed('p') or keyboard.is_pressed('P'):
            self.sign4return_data['data'] = 6
            self.sign4return_pub.publish(self.sign4return_data)
            self.sign4return_data['data'] = 0
            sleep(0.5)
        if keyboard.is_pressed('j') or keyboard.is_pressed('J'):
            self.llm_data['data'] = 1
            self.llm_pub.publish(self.llm_data)
            sleep(1)

半场扫码优化

USB 相机拍摄的照片清晰度有限,而深度相机在扫码场景下表现更好。建议仅在任务一且小车越过半场(全局坐标 x > 2m)时开启扫码节点,以节省 CPU 资源。

文章配图 图 3 深度相机扫码效果

文章配图 图 4 USB 相机扫码效果

以下是扫码节点的简化逻辑:

import rclpy
from rclpy.node import Node
import cv2
import numpy as np
from sensor_msgs.msg import Image
from std_msgs.msg import String, Int32
from nav_msgs.msg import Odometry
from origincar_msg.msg import Sign
from cv_bridge import CvBridge

TASK1 = 1
TASK2_WAITFOR_CMD = 2
TASK2 = 3
TASK3 = 4
TASK_STOP = 5

class QrCodeDetection(Node):
    def __init__(self):
        super().__init__('QRcodeSub')
        self.Sign4ReturnSub = self.create_subscription(Int32, 'sign4return', self.sign4return_callback, 10)
        self.ImageSub = self.create_subscription(Image, '/aurora/rgb/image_raw', self.image_callback, 10)
        self.OdomSub = self.create_subscription(Odometry, '/odom_combined', self.Odom_callback, 10)
        self.qrcode_publisher = self.create_publisher(String, "/qrcode_information", 10)
        self.info_result = String()
        self.sign_publisher = self.create_publisher(Sign, '/sign_switch', 10)
        self.sign_msg = Sign()
        self.detector = cv2.wechat_qrcode_WeChatQRCode(
            "/userdata/WorkSpace/codes/src/qrcode/qrcode/model/detect.prototxt",
            "/userdata/WorkSpace/codes/src/qrcode/qrcode/model/detect.caffemodel",
            "/userdata/WorkSpace/codes/src/qrcode/qrcode/model/sr.prototxt",
            "/userdata/WorkSpace/codes/src/qrcode/qrcode/model/sr.caffemodel"
        )
        self.bridge = CvBridge()
        self.node_run = False
        self.task = TASK1

    def image_callback(self, msg):
        if self.node_run and (self.task == TASK1 or self.task == TASK2):
            cv2_image = self.bridge.imgmsg_to_cv2(msg, desired_encoding='mono8')[155:,:]
            res = self.detector.detectAndDecode(cv2_image)[0]
            if res:
                self.node_run = False
                for r in res:
                    self.info_result.data = str(r)
                    self.qrcode_publisher.publish(self.info_result)
                    self.get_logger().info(f"{self.info_result.data}")
                    if self.info_result.data == "AntiClockWise":
                        self.sign_msg.sign_data = 4
                    elif self.info_result.data == "ClockWise":
                        self.sign_msg.sign_data = 3
                    else:
                        try:
                            data = int(r)
                            self.sign_msg.sign_data = 3 if data % 2 else 4
                        except: pass
                    self.sign_publisher.publish(self.sign_msg)
                    self.info_result.data = "None"
                    self.sign_msg.sign_data = 0

    def sign4return_callback(self, msg):
        if msg.data == 0 or msg.data == -1:
            self.task = TASK1
            self.node_run = False
        elif msg.data == 5:
            self.task = TASK2
        elif msg.data == 6:
            self.task = TASK3

    def Odom_callback(self, msg):
        if self.task == TASK1 and msg.pose.pose.position.x > 2:
            self.node_run = True

为减少计算量,建议裁剪图像,仅保留有效扫码区域。

准确返回 P 点

思路 1——利用地图固定元素校准

每次重置里程计后,将起点设为原点。通过逆透视变换计算前方两条线的相对位置,结合已知终点坐标,求解旋转矩阵和平移向量,从而推算出当前视角下的 P 点坐标。

文章配图 图 6 固定小橙的位置,终点是 (1.9m, -1.5m)

文章配图 图 7 地图固定元素示意

核心计算公式如下:

def end_point(x1, y1, x2, y2, x3, y3, x1_, y1_, x2_, y2_):
    delta_x = x1 - x2
    delta_y = y1 - y2
    delta_x_ = x1_ - x2_
    delta_y_ = y1_ - y2_
    den = delta_x ** 2 + delta_y ** 2
    a = (delta_x * delta_x_ + delta_y * delta_y_) / den
    b = (delta_x * delta_y_ - delta_y * delta_x_) / den
    tx = x1_ - a * x1 + b * y1
    ty = y1_ - b * x1 - a * y1
    x3_ = a * x3 - b * y3 + tx
    y3_ = b * x3 + a * y3 + ty
    print(f"(x1, y1): ({x1}, {y1}), (x2, y2): ({x2}, {y2}), (x3, y3): ({x3}, {y3})")
    return x3_, y3_

思路 2——YOLO 识别校正

不重置里程计,直接使用 YOLO 识别 P 点,结合单应性矩阵计算全局坐标。此方法无需停车,适合高速场景。

# 单应性矩阵,计算全局坐标
H = np.array([
    [-4.66389128e-04, -2.26288030e-04, -4.92300831e-02],
    [7.59821540e-04, 5.20569143e-05, -2.33074608e-01],
    [-6.59643252e-04, -7.15022786e-03, 1.00000000e+00],
])

def pixel2global(self, pixel_x, pixel_y):
    pixel = np.array([pixel_x, pixel_y, 1], dtype=np.float32)
    local = np.dot(H, pixel)
    local /= local[2]
    local[0] += 0.25
    car_cos = np.cos(self.current_pos[2])
    car_sin = np.sin(self.current_pos[2])
    global_x = self.current_pos[0] + car_cos * local[0] - car_sin * local[1]
    global_y = self.current_pos[1] + car_sin * local[0] + car_cos * local[1]
    return global_x, global_y

训练数据需覆盖各种角度和遮挡情况,建议采集并增强至 2 万张以上。

修改 STM32 源码

STM32 底层控制对车辆性能影响显著。主要调整包括舵机转角多项式系数,确保左右转向对称;提高串口发送频率至 50Hz,波特率设为 921600;关闭非必要外设(CAN、蓝牙);修改 EKF 配置以匹配新的 IMU 频率。

文章配图 图 舵机转角限制

文章配图 图 多项式输入输出

文章配图 图 右前轮转向角限幅

文章配图 图 原始多项式结果

文章配图 图 调整后的多项式

文章配图 图 对称性验证

文章配图 图 串口配置

文章配图 图 外设关闭

文章配图 图 启动文件修改

文章配图 图 EKF 频率设置

文章配图 图 EKF 配置对比

补充:数据处理脚本

为提高效率,建议使用预训练模型自动标注,再人工复核。以下提供删除无效数据、数据增强及分批次打包的脚本。

自动标注脚本

import argparse
import os
import shutil
import time
from pathlib import Path
import torch
import cv2
from models.experimental import attempt_load
from utils.datasets import LoadImages
from utils.utils import non_max_suppression, scale_coords, xyxy2xywh
from utils.torch_utils import select_device, time_synchronized

def auto_annotate(source, weights, output, img_size=640, conf_thres=0.25, iou_thres=0.45, view_img=False):
    device = select_device(device)
    half = device.type != 'cpu'
    model = attempt_load(weights, map_location=device)
    names = model.module.names if hasattr(model, 'module') else model.names
    dataset = LoadImages(source, img_size=imgsz)
    t0 = time.time()
    img = torch.zeros((1, 3, imgsz, imgsz), device=device)
    _ = model(img.half() if half else img)
    for path, img, im0s, _ in dataset:
        img = torch.from_numpy(img).to(device)
        img = img.half() if half else img.float()
        img /= 255.0
        if img.ndimension() == 3:
            img = img.unsqueeze(0)
        t1 = time_synchronized()
        pred = model(img, augment=False)[0]
        pred = non_max_suppression(pred, conf_thres, iou_thres, classes=None, agnostic=False)
        t2 = time_synchronized()
        p, im0 = path, im0s.copy()
        txt_path = str(Path(output) / Path(p).stem) + ('.txt')
        open(txt_path, 'w').close()
        gn = torch.tensor(im0.shape)[[1, 0, 1, 0]]
        if pred is not None:
            for i, det in enumerate(pred):
                if det is not None and len(det):
                    det[:, :4] = scale_coords(img.shape[2:], det[:, 4], im0.shape).round()
                    with open(txt_path, 'w') as f:
                        for *xyxy, conf, cls in reversed(det):
                            xywh = (xyxy2xywh(torch.tensor(xyxy).view(1, 4)) / gn).view(-1).tolist()
                            line = "%d %.6f %.6f %.6f %.6f" % (cls, *xywh)
                            f.write(line + "\n")
        print(f'{Path(p).name} done. ({t2 - t1:.3f}s)')

删除无效数据

import os
from pathlib import Path

def remove_invalid_images_labels(image_dir, label_dir):
    deleted_images = 0
    deleted_labels = 0
    for image_file in os.listdir(image_dir):
        if image_file.lower().endswith(('.jpg', '.png', '.jpeg')):
            image_path = os.path.join(image_dir, image_file)
            label_path = os.path.join(label_dir, Path(image_file).stem + '.txt')
            if not os.path.exists(label_path):
                os.remove(image_path)
                deleted_images += 1
                print(f"删除图片(无标签): {image_file}")
            else:
                with open(label_path, 'r') as f:
                    content = f.read().strip()
                    if not content:
                        os.remove(image_path)
                        os.remove(label_path)
                        deleted_images += 1
                        deleted_labels += 1
                        print(f"删除无效数据:{image_file} 和对应标签")
    print(f"\n操作完成!共删除:{deleted_images} 张图片,{deleted_labels} 个标签")

数据增强

import torch
import torchvision.transforms as T
import torchvision.transforms.functional as TF
from pathlib import Path
import shutil
from PIL import Image
import random
from multiprocessing import Pool
import os

class YOLOAugment:
    def __init__(self, output_dir):
        self.output_dir = output_dir
        Path(f"{output_dir}/images").mkdir(parents=True, exist_ok=True)
        Path(f"{output_dir}/labels").mkdir(parents=True, exist_ok=True)
        self.img_augment = T.Compose([
            T.ColorJitter(brightness=0.3, contrast=0.3, saturation=0.2),
            T.GaussianBlur(kernel_size=(3, 7))
        ])

    def apply_augment(self, img_path, label_path, aug_id):
        img = Image.open(img_path).convert('RGB')
        with open(label_path) as f:
            bboxes = [list(map(float, line.strip().split())) for line in f]
        img_tensor = TF.to_tensor(img)
        bboxes_tensor = torch.tensor(bboxes)
        img_tensor = self.img_augment(img_tensor)
        stem = Path(img_path).stem
        self._save_results(img_tensor, bboxes_tensor, stem, aug_id)
        return img, bboxes

    def _save_results(self, img_tensor, bboxes, stem, aug_id):
        aug_img = TF.to_pil_image(img_tensor)
        aug_img.save(f"{self.output_dir}/images/{stem}_aug{aug_id}.jpg")
        with open(f"{self.output_dir}/labels/{stem}_aug{aug_id}.txt", 'w') as f:
            for bbox in bboxes.numpy():
                line = ' '.join(map(str, bbox))
                f.write(line + '\n')

def process_file(args):
    img_path, label_path, output_dir, aug_per_image = args
    augmenter = YOLOAugment(output_dir)
    for i in range(1, aug_per_image + 1):
        augmenter.apply_augment(img_path, label_path, i)
    shutil.copy(img_path, f"{output_dir}/images/{Path(img_path).name}")
    shutil.copy(label_path, f"{output_dir}/labels/{Path(label_path).name}")

if __name__ == "__main__":
    root_path = os.path.dirname(__file__)
    input_dir = os.path.join(root_path, "new1")
    output_dir = os.path.join(root_path, "new1_aug")
    aug_per_image = 3
    num_workers = 4
    tasks = []
    for img_file in Path(f"{input_dir}/images").glob("*.*"):
        if img_file.suffix.lower() in ('.jpg', '.png', '.jpeg'):
            label_file = Path(f"{input_dir}/labels/{img_file.stem}.txt")
            if label_file.exists():
                tasks.append((str(img_file), str(label_file), output_dir, aug_per_image))
    print(f"开始增强 {len(tasks)} 张图像...")
    with Pool(processes=num_workers) as pool:
        pool.map(process_file, tasks)
    orig_count = len(tasks)
    aug_count = orig_count * aug_per_image
    print(f"处理完成!\n- 原始图像保留:{orig_count} 张\n- 增强图像生成:{aug_count} 张\n- 总数据量:{orig_count + aug_count} 张")

分批次打包

import os
import zipfile
import math
from pathlib import Path

def create_task_packs(images_dir, labels_dir, output_dir, tasks=3, label_txt=False):
    image_files = sorted([f for f in os.listdir(images_dir) if f.endswith(('.jpg', '.png'))])
    label_files = sorted([f for f in os.listdir(labels_dir) if f.endswith('.txt')])
    image_stems = {Path(f).stem for f in image_files}
    label_stems = {Path(f).stem for f in label_files}
    unmatched = image_stems.symmetric_difference(label_stems)
    if unmatched:
        print(f"⚠️ 警告:发现 {len(unmatched)} 个不匹配文件")
        return
    total_pairs = len(image_files)
    pairs_per_task = math.ceil(total_pairs / tasks)
    print(f"数据集统计:\n- 图片数量:{len(image_files)}\n- 标注数量:{len(label_files)}\n- 将分成 {tasks} 个任务包,每个约 {pairs_per_task} 对数据\n")
    os.makedirs(output_dir, exist_ok=True)
    for task_num in range(1, tasks + 1):
        start_idx = (task_num - 1) * pairs_per_task
        end_idx = min(start_idx + pairs_per_task, total_pairs)
        task_images = image_files[start_idx:end_idx]
        task_labels = [Path(f).stem + '.txt' for f in task_images]
        zip_path = os.path.join(output_dir, f"task_{task_num}.zip")
        print(f"创建任务包 {task_num}:\n- 包含图片:{len(task_images)} 张\n- 包含标注:{len(task_labels)} 个\n- 保存到:{zip_path}")
        with zipfile.ZipFile(zip_path, 'w', zipfile.ZIP_DEFLATED) as zipf:
            for img in task_images:
                img_path = os.path.join(images_dir, img)
                zipf.write(img_path, f"images/{img}")
            for label in task_labels:
                label_path = os.path.join(labels_dir, label)
                if os.path.exists(label_path):
                    zipf.write(label_path, f"labels/{label}")
                else:
                    print(f"⚠️ 缺失标注文件:{label}")
    print(f"\n🎉 任务包创建完成!共生成 {tasks} 个压缩包,保存在:{output_dir}")

if __name__ == "__main__":
    root_path = os.path.dirname(__file__)
    dataset_dir = os.path.join(root_path, "new1")
    output_dir = os.path.join(root_path, "package")
    label_txt = os.path.join(root_path, "labels.txt")
    num_tasks = 4
    create_task_packs(
        images_dir=os.path.join(dataset_dir, "images"),
        labels_dir=os.path.join(dataset_dir, "labels"),
        output_dir=output_dir,
        tasks=num_tasks,
        # label_txt=label_txt,
    )

后记

希望这些经验能为后续参赛的同学提供参考。

目录

  1. 前言
  2. 网络优化
  3. 上位机辅助处理
  4. 半场扫码优化
  5. 准确返回 P 点
  6. 思路 1——利用地图固定元素校准
  7. 思路 2——YOLO 识别校正
  8. 单应性矩阵,计算全局坐标
  9. 修改 STM32 源码
  10. 补充:数据处理脚本
  11. 自动标注脚本
  12. 删除无效数据
  13. 数据增强
  14. 分批次打包
  15. 后记

更多推荐文章

查看全部
  • agent-browser:Vercel 开源的 AI 智能体浏览器自动化 CLI 工具
  • GitHub Copilot Agent Skills:打造跨项目 AI 专属工具箱
  • 大模型核心技术详解:架构、训练与微调
  • NLP 领域最优秀的预训练模型详解
  • SQL 高级查询技巧与预处理语句实战详解
  • 基于 Miniconda 与 Python 3.9 部署 Stable Diffusion 模型
  • RAG 四大模式解析:Naive、Advanced、Modular 与 Agentic
  • Z-Image 深度评测:无需登录注册的免费 AI 图像生成工具
  • Windows 安装 Ubuntu 与 ROS 环境配置指南
  • 10 款降低 AIGC 检测率的学术写作工具介绍
  • 使用 VPN 后 Mac 出现能联网但无法访问网页的问题
  • RAD Studio 13 Florence:Delphi 与 C++ Builder 的新变化
  • Docker 部署 MoviePilot V2 自动化影视管理平台教程
  • PythonWin7 解决 Windows 7 Python 兼容性问题指南
  • Happy Coder:Claude Code 的移动端与 Web 客户端方案
  • MySQL 表连接详解:Inner Join、Left Join、Right Join
  • MySQL 迁移至金仓:权限隔离与数据安全增强
  • Seedance 2.0 实操指南:从入门到多模态视频生成
  • Copilot的Plan模式到底好在哪?
  • Android 插件化开发:代理 Activity 模式详解

相关免费在线工具

  • 加密/解密文本

    使用加密算法(如AES、TripleDES、Rabbit或RC4)加密和解密文本明文。 在线工具,加密/解密文本在线工具,online

  • RSA密钥对生成器

    生成新的随机RSA私钥和公钥pem证书。 在线工具,RSA密钥对生成器在线工具,online

  • Mermaid 预览与可视化编辑

    基于 Mermaid.js 实时预览流程图、时序图等图表,支持源码编辑与即时渲染。 在线工具,Mermaid 预览与可视化编辑在线工具,online

  • 随机西班牙地址生成器

    随机生成西班牙地址(支持马德里、加泰罗尼亚、安达卢西亚、瓦伦西亚筛选),支持数量快捷选择、显示全部与下载。 在线工具,随机西班牙地址生成器在线工具,online

  • Gemini 图片去水印

    基于开源反向 Alpha 混合算法去除 Gemini/Nano Banana 图片水印,支持批量处理与下载。 在线工具,Gemini 图片去水印在线工具,online

  • curl 转代码

    解析常见 curl 参数并生成 fetch、axios、PHP curl 或 Python requests 示例代码。 在线工具,curl 转代码在线工具,online