Bootstrap

AGV小车天花板巡线运动

一、背景与具体实现功能。

       在传统的 AGV 小车巡线作业场景中,诸如电磁导航、地面色带导航以及磁条导航等方式较为常见,它们通常需要在 AGV 小车的行进路面上进行特殊处理,如埋设金属线、铺设色带或磁条。

       然而,这种依赖地面标识的导航手段存在明一些弊端:一方面,随着使用频次的增加,色带极易出现磨损,磁条也可能发生消磁状况,一旦出现此类问题,AGV 小车的导航精准度便会大打折扣,甚至完全失效;另一方面,倘若生产流程需要调整,或是物流布局有所变动,想要对这些地面导航标识进行变更,操作难度颇高,不仅耗时费力,还可能影响正常生产秩序。

        鉴于此,我们可尝试利用天花板上的固有特征来实现 AGV 小车的巡线引导,选定天花板高低不平的交界线作为潜在的巡线轨迹,期望以此作为初步尝试,让 AGV 小车开启全新的巡线模式。

(如上图所示,初步采用识别红框中的天花板高度不一的交界线作为巡线轨迹)

二、基本物料。

小车采用古月居的Origincar小车套件,自配双目鱼眼相机

1. 主控板:Origincar小车采用地平线RDK X3板,作为主控板;

2. 运动控制板:Origincar小车采用STM32F407VET6芯片,作为运动控制板;

3. 相机:自配双目鱼眼相机,但巡线使用时,实际只采用一个相机采集的图像;

三、基本功能实现流程。

1. 打开相机,拍摄天花板及天花板上的边缘线;

2. 识别图像中的边缘线,并根据边缘线识别特征点,绘制出巡线轨迹;

3. 沿着新绘制的线,发布机器人的运动指令,使机器人沿着线运动。

四、流程细节。

1. 打开相机/拍摄图像/图像矫正处理:

       由于使用的是鱼眼相机,则需要对图像进行校正,此处选用桶形展开校正,本代码中是对本地图像进行校正,也可以直接从相机读取图像进行校正:

       首先需要使用棋盘格图片对鱼眼相机采集的图像进行校正,得到光心坐标、相机的内参与畸变参数,再根据这些内容,来进行鱼眼图像的桶型矫正;

import cv2
import numpy as np
import os

# 定义输入和输出文件夹路径
input_folder = '输入文件夹路径'
output_folder = '桶型矫正后输出文件夹路径'

# 确保输出文件夹存在
os.makedirs(output_folder, exist_ok=True)

# 获取文件夹中的所有 .bmp 文件
image_files = [f for f in os.listdir(input_folder) if f.endswith('.bmp')]

# 相机内参和畸变参数
K = np.array([[585.6065978157075, 0.0, 583.2996761313536],
              [0.0, 583.7935461839306, 336.5316008144147],
              [0.0, 0.0, 1.0]])
D = np.array([-0.17860062239081786, 0.004389935056111613, 0.023457691555598112, -0.011916211941152808])

# 定义光心坐标
cx, cy = 583.2996761313536, 336.5316008144147

# 处理每个图像文件
for image_file in image_files:
    image_path = os.path.join(input_folder, image_file)
    image = cv2.imread(image_path, cv2.IMREAD_COLOR)

    if image is None:
        print(f"Image not found or cannot be read at path: {image_path}")
        continue

    # 图像尺寸
    dim = image.shape[:2][::-1]  # (width, height)

    # 进行桶形畸变校正
    map1, map2 = cv2.fisheye.initUndistortRectifyMap(K, D, np.eye(3), K, dim, cv2.CV_16SC2)
    undistorted_image = cv2.remap(image, map1, map2, interpolation=cv2.INTER_LINEAR, borderMode=cv2.BORDER_CONSTANT)

    # 在校正前后的图像中标记光心
    marked_image = image.copy()
    marked_undistorted_image = undistorted_image.copy()
    cv2.circle(marked_image, (int(cx), int(cy)), 5, (0, 0, 255), -1)
    cv2.circle(marked_undistorted_image, (int(cx), int(cy)), 5, (0, 0, 255), -1)

    # 保存校正后的图像到本地
    output_path = os.path.join(output_folder, f"undistorted_{image_file}")
    cv2.imwrite(output_path, undistorted_image)
    print(f"Undistorted image saved to {output_path}")

2.图像处理,找出梯度最高点并进行裁剪:

       以相机的光心为中心,将梯度明显的部分计算提取出来,标记梯度最高的点并裁剪;

import cv2
import numpy as np
import os

# 定义输入和输出文件夹路径
input_folder = '刚才保存的桶型矫正后输出文件夹路径'  
output_folder = '新的处理后输出文件夹路径' 

# 确保输出文件夹存在
os.makedirs(output_folder, exist_ok=True)

# 获取文件夹中的所有 .bmp 文件
image_files = [f for f in os.listdir(input_folder) if f.endswith('.bmp')]

# 定义光心坐标
cx, cy = 583.2996761313536, 336.5316008144147

# 定义搜索区域大小
search_radius = 50  # 可以根据需要调整搜索半径

# 处理每个图像文件
for image_file in image_files:
    image_path = os.path.join(input_folder, image_file)
    image = cv2.imread(image_path, cv2.IMREAD_GRAYSCALE)

    if image is None:
        print(f"Image not found or cannot be read at path: {image_path}")
        continue

    # 计算梯度
    grad_x = cv2.Sobel(image, cv2.CV_64F, 1, 0, ksize=3)
    grad_y = cv2.Sobel(image, cv2.CV_64F, 0, 1, ksize=3)

    # 计算梯度幅度
    grad_magnitude = np.sqrt(grad_x**2 + grad_y**2)

    # 将梯度幅度图像转换为 uint8 类型
    grad_magnitude = (grad_magnitude / grad_magnitude.max() * 255).astype(np.uint8)

    # 在光心附近的区域内找到梯度最大的点
    search_area = grad_magnitude[int(cy - search_radius):int(cy + search_radius), int(cx - search_radius):int(cx + search_radius)]
    max_grad_index = np.unravel_index(np.argmax(search_area), search_area.shape)
    max_grad_y, max_grad_x = max_grad_index[0] + int(cy - search_radius), max_grad_index[1] + int(cx - search_radius)

    # 以最大梯度点为中心裁剪一张长300宽300像素的图像
    crop_size = 300
    half_crop_size = crop_size // 2
    x_min = int(max_grad_x - half_crop_size)
    y_min = int(max_grad_y - half_crop_size)
    x_max = x_min + crop_size
    y_max = y_min + crop_size

    # 确保裁剪区域在图像范围内
    if x_min < 0 or y_min < 0 or x_max > image.shape[1] or y_max > image.shape[0]:
        print(f"Crop region out of bounds for image: {image_file}")
        continue

    cropped_image = grad_magnitude[y_min:y_max, x_min:x_max]

    # 在裁剪后的图像中标记梯度最大的点
    marked_cropped_image = cv2.cvtColor(cropped_image, cv2.COLOR_GRAY2BGR)
    cv2.circle(marked_cropped_image, (half_crop_size, half_crop_size), 5, (0, 0, 255), -1)

    # 保存标记后的裁剪图像
    base_name = os.path.splitext(image_file)[0]
    marked_cropped_output_path = os.path.join(output_folder, f'{base_name}_marked_cropped.bmp')

    cv2.imwrite(marked_cropped_output_path, marked_cropped_image)

    print(f"Marked cropped image saved to {marked_cropped_output_path}")

为保证图像处理结果更加准确清晰,相机垂直仰拍天花板,并使用一盏白光灯进行补光;

初始时白光灯垂直仰视照射天花板,发现处理后的图像存在干扰,找出的特征点并不在需要巡线的轨迹上,于是将白光灯换到了图像左侧,由左向右进行打光;

(如上两幅图,为白光灯垂直仰视照射天花板的效果与处理效果,处理后的图像中,竖直的白色线条为处理后的天花板交界线,即为需要的巡线轨迹;红色点即为所需要识别出的特征点——梯度最高点,处理后的结果可看出部分找出的特征点并不在需要巡线的轨迹上

(如上两幅图,为白光灯由左向右进行打光的效果与处理效果,处理后的图像中,红色点的特征点——梯度最高点均在需要巡线的轨迹上)

五、整体流程整理与运动控制。

       以上流程走通后,对各部分代码进行整理,小车需要进行视觉巡线,目前已经得到所获取图像中梯度最高的点,那么可以得到梯度最高的点上方每隔五行、取一行,取出的那一行梯度最高的点,进行筛选与处理,形成一条路径,可以确定机器人的前进方向和角度。

1. 图像采集与处理代码整理:

       在开启摄的像头、矫正、裁剪、找出初始的梯度最大点之后,再将初始的梯度最大点上方,每隔五行、取一行的梯度最大点取出,并判断这些梯度最大点:

       如果它们和初始的梯度最大点,在x方向上的差值小于一定距离,则可将他们和初始的梯度最大点进行连接,拟合出一条线,标记出此条线(标记时可将线条适当加宽),以此作为小车的巡线轨迹;

       最终将处理好的图像发布到话题中,为下一步处理做准备。

import rclpy
from rclpy.node import Node
from sensor_msgs.msg import CompressedImage
from cv_bridge import CvBridge
import cv2
import numpy as np
import os
import time
import datetime


class CameraProcessNode(Node):
    def __init__(self):
        super().__init__('camera_process_node')
        self.cap = cv2.VideoCapture(0, cv2.CAP_V4L2)  
        if not self.cap.isOpened():
            self.get_logger().error("Failed to open camera")
            return

        # 设置摄像头分辨率
        self.width = 1280  
        self.height = 720  
        self.cap.set(cv2.CAP_PROP_FRAME_WIDTH, self.width)
        self.cap.set(cv2.CAP_PROP_FRAME_HEIGHT, self.height)

        # 检查分辨率设置是否成功
        actual_width = int(self.cap.get(cv2.CAP_PROP_FRAME_WIDTH))
        actual_height = int(self.cap.get(cv2.CAP_PROP_FRAME_HEIGHT))
        self.get_logger().info(f"Set resolution to {self.width}x{self.height}, actual resolution is {actual_width}x{actual_height}")

        self.publisher = self.create_publisher(CompressedImage, 'image/compressed', 5)
        self.bridge = CvBridge()
        self.timer = self.create_timer(1.0 / 15, self.process_and_publish)  

        # 相机内参和畸变参数
        self.K = np.array([[585.6065978157075, 0.0, 583.2996761313536],
                           [0.0, 583.7935461839306, 336.5316008144147],
                           [0.0, 0.0, 1.0]])
        self.D = np.array([-0.17860062239081786, 0.004389935056111613, 0.023457691555598112, -0.11916211941152808])
        self.cx, self.cy = 583.2996761313536 , 336.5316008144147  
        self.search_radius = 50  
        self.crop_width = 300  
        self.crop_height = 200  

        # 创建以当前时间戳命名的输出文件夹
        current_time = datetime.datetime.now().strftime('%Y%m%d%H%M%S')
        self.output_folder = os.path.join('图像保存的本地路径', current_time)
        os.makedirs(self.output_folder, exist_ok=True)

    def process_and_publish(self):
        ret, frame = self.cap.read()
        if ret:
            # 图像尺寸
            dim = frame.shape[:2][::-1]  # (width, height)

            # 进行桶形畸变校正
            map1, map2 = cv2.fisheye.initUndistortRectifyMap(self.K, self.D, np.eye(3), self.K, dim, cv2.CV_16SC2)
            undistorted_image = cv2.remap(frame, map1, map2, interpolation=cv2.INTER_LINEAR, borderMode=cv2.BORDER_CONSTANT)

            # 转换为灰度图
            gray_image = cv2.cvtColor(undistorted_image, cv2.COLOR_BGR2GRAY)

            # 计算梯度
            grad_x = cv2.Sobel(gray_image, cv2.CV_64F, 1, 0, ksize=3)
            grad_y = cv2.Sobel(gray_image, cv2.CV_64F, 0, 1, ksize=3)

            # 计算梯度幅度
            grad_magnitude = np.sqrt(grad_x ** 2 + grad_y ** 2)

            # 将梯度幅度图像转换为 uint8 类型
            grad_magnitude = (grad_magnitude / grad_magnitude.max() * 255).astype(np.uint8)

            # 在新的光心附近的区域内找到梯度最大的点
            search_area = grad_magnitude[int(self.cy - self.search_radius):int(self.cy + self.search_radius),
                                         int(self.cx - self.search_radius):int(self.cx + self.search_radius)]
            max_grad_index = np.unravel_index(np.argmax(search_area), search_area.shape)
            max_grad_y, max_grad_x = max_grad_index[0] + int(self.cy - self.search_radius), max_grad_index[1] + int(self.cx - self.search_radius)

            # 以最大梯度点为中心裁剪一张长100宽300像素的图像
            half_crop_width = self.crop_width // 2
            half_crop_height = self.crop_height // 2
            x_min = int(max_grad_x - half_crop_width)
            y_min = int(max_grad_y - half_crop_height)
            x_max = x_min + self.crop_width
            y_max = y_min + self.crop_height

            # 确保裁剪区域在图像范围内
            if x_min < 0 or y_min < 0 or x_max > gray_image.shape[1] or y_max > gray_image.shape[0]:
                self.get_logger().warn("Crop region out of bounds")
                return

            cropped_image = grad_magnitude[y_min:y_max, x_min:x_max]

            # 在裁剪后的图像中标记梯度最大的点
            marked_cropped_image = cv2.cvtColor(cropped_image, cv2.COLOR_GRAY2BGR)
            cv2.circle(marked_cropped_image, (half_crop_width, half_crop_height), 5, (0, 0, 255), -1)

            # 找出梯度最高的点上方每隔5行的一行梯度最高的点
            step = 5
            points = []
            for y in range(0, half_crop_height, step):
                line = cropped_image[y, :]
                max_val_index = np.argmax(line)
                points.append((max_val_index, y))

            # 以第一个梯度最高的点为基准,依次连接这些梯度最高的点
            base_point = (half_crop_width, half_crop_height)
            for point in points:
                dist = abs(point[0] - base_point[0])
                if dist <= 10:  # 如果平移距离小于等于10列,则连接这两个点
                    cv2.line(marked_cropped_image, (base_point[0], base_point[1]), (point[0], point[1]), (0, 0, 255), 30)

            # 保存处理后的图像,按顺序编号保存
            file_index = 1
            output_path = os.path.join(self.output_folder, f'processed_{file_index}.jpg')
            while os.path.exists(output_path):
                file_index += 1
                output_path = os.path.join(self.output_folder, f'processed_{file_index}.jpg')
            cv2.imwrite(output_path, marked_cropped_image)

            # 压缩图像并发布
            compressed_msg = self.bridge.cv2_to_compressed_imgmsg(marked_cropped_image, "jpeg")
            self.publisher.publish(compressed_msg)
        else:
            self.get_logger().warn("Failed to capture frame")

    def destroy_node(self):
        self.cap.release()
        self.get_logger().info("Camera released")


def main(args=None):
    rclpy.init(args=args)
    camera_node = CameraProcessNode()

    try:
        rclpy.spin(camera_node)
    except KeyboardInterrupt:
        camera_node.get_logger().info("Shutting down")
    finally:
        camera_node.destroy_node()
        rclpy.shutdown()


if __name__ == '__main__':
    main()
    

处理结果如下图,红色线即为小车巡线所需轨迹:

2. 运动控制部分:

       订阅图像采集与处理代码中发布的话题,并对话题中的图像信息进行接收和解析,裁剪后识别图中的红色部分;

       将红色部分的重心,和裁剪后整个图像的中心进行重合,来控制小车的巡线运动;

(此时线速度为固定给出,角速度根据红色部分的重心,与整个图像的中心的偏移量计算得出)

#!/usr/bin/env python3

import cv2
import numpy as np
import rclpy
import time
from rclpy.node import Node
from sensor_msgs.msg import CompressedImage
from rclpy.qos import QoSProfile
import cv_bridge
from geometry_msgs.msg import Twist
import os
import datetime

# 红色的HSV范围
col_red = (0, 100, 80, 10, 255, 255)


class Follower(Node):
    def __init__(self):
        super().__init__('follower')
        self.bridge = cv_bridge.CvBridge()
        qos = QoSProfile(depth=10)
        self.image_sub = self.create_subscription(
            CompressedImage,
            'image/compressed',
            self.image_callback,
            qos)
        self.cmd_vel_pub = self.create_publisher(Twist, 'cmd_vel', qos)
        self.twist = Twist()
        self.last_erro = 0
        self.image_count = 0  

        # 创建以当前时间戳命名的输出文件夹
        current_time = datetime.datetime.now().strftime('%Y%m%d%H%M%S')
        self.save_path = os.path.join('输出图像文件夹', current_time)
        os.makedirs(self.save_path, exist_ok=True)  

    def image_callback(self, msg):
        # 解压图像消息为OpenCV图像
        image = self.bridge.compressed_imgmsg_to_cv2(msg, desired_encoding='bgr8')
        h, w, d = image.shape

        # 截取图像的上半部分
        image_top_half = image[:h // 2, :]

        # 将截取的图像转换为HSV颜色空间
        hsv = cv2.cvtColor(image_top_half, cv2.COLOR_BGR2HSV)

        # 使用固定的红色HSV范围
        lowerbH, lowerbS, lowerbV = col_red[:3]
        upperbH, upperbS, upperbV = col_red[3:]

        mask = cv2.inRange(hsv, (lowerbH, lowerbS, lowerbV), (upperbH, upperbS, upperbV))
        masked = cv2.bitwise_and(image_top_half, image_top_half, mask=mask)

        # 计算mask图像的重心
        M = cv2.moments(mask)
        if M['m00'] > 0:
            cx = int(M['m10'] / M['m00'])
            cy = int(M['m01'] / M['m00'])

            # 计算目标重心与图像中心的偏移量
            erro = cx - w // 2
            d_erro = erro - self.last_erro

            # 控制机器人的线速度和角速度
            self.twist.linear.x = 0.11
            self.twist.angular.z = -(float(erro) * 0.0011 - float(d_erro) * 0.0000)
            self.last_erro = erro

            # 在图像上标记重心
            cv2.circle(image_top_half, (cx, cy), 10, (0, 255, 0), -1)  
            self.get_logger().info(f"Marked center at ({cx}, {cy + h // 2})")  
        else:
            self.twist.linear.x = 0.0
            self.twist.angular.z = 0.0
            self.get_logger().info("No red object detected")  

        # 发布速度命令
        self.cmd_vel_pub.publish(self.twist)

        # 显示调试图像
        cv2.imshow("Adjust_hsv", mask)
        cv2.imshow("Image with Center Marked", image_top_half)  

        # 保存图像,按顺序编号保存
        file_index = 1
        filename = os.path.join(self.save_path, f'center_marked_image_{file_index}.jpg')
        while os.path.exists(filename):
            file_index += 1
            filename = os.path.join(self.save_path, f'center_marked_image_{file_index}.jpg')

        cv2.imwrite(filename, image_top_half)  

    def destroy_node(self):
        cv2.destroyAllWindows()
        super().destroy_node()


def main(args=None):
    rclpy.init(args=args)
    print('start patrolling')
    follower = Follower()
    while rclpy.ok():
        rclpy.spin_once(follower)
        time.sleep(0.2)

    follower.destroy_node()
    rclpy.shutdown()


if __name__ == '__main__':
    main()

处理结果如下图,红色线即为小车巡线所需轨迹,绿色为红色部分的重心:

3. 一键启动:

(1)大致逻辑:使用一条命令,就可启动小车底盘、启动处理代码、启动巡线代码;

(2)目前小车巡线功能的代码分成了多个文件,需要将这些文件进行一键启动,方便操作,则计划更改小车启动方式为:1个启动文件,使用1条命令进行启动,这条命令将同时启动图像处理,外加小车巡线节点;

         以上,则需要:①总体启动代码、②图像处理代码、③小车巡线代码;其中②、③已有,差一个总体启动代码可命名为【xxxx.launch.py】;

import os
from ament_index_python.packages import get_package_share_directory
from launch import LaunchDescription
from launch.actions import DeclareLaunchArgument
from launch.substitutions import LaunchConfiguration
from launch_ros.actions import Node

def generate_launch_description():
    photo2_node = Node(
        package='功能包名',
        executable='可执行文件名1',
        name='节点名1',
        output='screen'
    )

    sport_node = Node(
        package='功能包名',
        executable='可执行文件名2',
        name='节点名2',
        output='screen'
    )

    return LaunchDescription([
        节点1,
        节点2
    ])

4. 将多个代码整合成一个代码,可不使用.launch.py文件:

#!/usr/bin/env python3

import rclpy
from rclpy.node import Node
from sensor_msgs.msg import CompressedImage
from geometry_msgs.msg import Twist
from cv_bridge import CvBridge
import cv2
import numpy as np
import os
import datetime
import time


class ImageProcessorAndFollower(Node):
    def __init__(self):
        super().__init__('image_processor_and_follower')
        self.cap = cv2.VideoCapture(0, cv2.CAP_V4L2)  
        if not self.cap.isOpened():
            self.get_logger().error("Failed to open camera")
            return

        # 设置摄像头分辨率
        self.width = 1280
        self.height = 720
        self.cap.set(cv2.CAP_PROP_FRAME_WIDTH, self.width)
        self.cap.set(cv2.CAP_PROP_FRAME_HEIGHT, self.height)

        # 检查分辨率设置是否成功
        actual_width = int(self.cap.get(cv2.CAP_PROP_FRAME_WIDTH))
        actual_height = int(self.cap.get(cv2.CAP_PROP_FRAME_HEIGHT))
        self.get_logger().info(f"Set resolution to {self.width}x{self.height}, actual resolution is {actual_width}x{actual_height}")

        self.publisher = self.create_publisher(CompressedImage, 'image/compressed', 5)
        self.bridge = CvBridge()
        self.cmd_vel_pub = self.create_publisher(Twist, 'cmd_vel', 5)
        self.twist = Twist()
        self.last_error = 0
        self.image_count = 0  

        # 相机内参和畸变参数
        self.K = np.array([[585.6065978157075, 0.0, 583.2996761313536],
                           [0.0, 583.7935461839306, 336.5316008144147],
                           [0.0, 0.0, 1.0]])
        self.D = np.array([-0.17860062239081786, 0.004389935056111613, 0.023457691555598112, -0.11916211941152808])
        self.cx, self.cy = 583.2996761313536 , 336.5316008144147  
        self.search_radius = 50  
        self.crop_width = 300  
        self.crop_height = 200  

        # 创建以当前时间戳命名的输出文件夹
        current_time = datetime.datetime.now().strftime('%Y%m%d%H%M%S')
        self.output_folder = os.path.join('图像输出路径', current_time)
        os.makedirs(self.output_folder, exist_ok=True)

        # 为不同类型的图像创建子文件夹
        self.processed_images_folder = os.path.join(self.output_folder, 'processed_images')
        self.center_marked_images_folder = os.path.join(self.output_folder, 'center_marked_images')
        os.makedirs(self.processed_images_folder, exist_ok=True)
        os.makedirs(self.center_marked_images_folder, exist_ok=True)

        self.timer = self.create_timer(1.0 / 15, self.process_and_follow)  

    def process_and_follow(self):
        ret, frame = self.cap.read()
        if ret:
            dim = frame.shape[:2][::-1]

            # 进行桶形畸变校正
            map1, map2 = cv2.fisheye.initUndistortRectifyMap(self.K, self.D, np.eye(3), self.K, dim, cv2.CV_16SC2)
            undistorted_image = cv2.remap(frame, map1, map2, interpolation=cv2.INTER_LINEAR, borderMode=cv2.BORDER_CONSTANT)

            # 第一次处理:裁剪并标记梯度最高的点
            gray_image = cv2.cvtColor(undistorted_image, cv2.COLOR_BGR2GRAY)
            grad_x = cv2.Sobel(gray_image, cv2.CV_64F, 1, 0, ksize=3)
            grad_y = cv2.Sobel(gray_image, cv2.CV_64F, 0, 1, ksize=3)
            grad_magnitude = np.sqrt(grad_x ** 2 + grad_y ** 2)
            grad_magnitude = (grad_magnitude / grad_magnitude.max() * 255).astype(np.uint8)

            # 在新的光心附近的区域内找到梯度最大的点
            search_area = grad_magnitude[int(self.cy - self.search_radius):int(self.cy + self.search_radius),
                                         int(self.cx - self.search_radius):int(self.cx + self.search_radius)]
            max_grad_index = np.unravel_index(np.argmax(search_area), search_area.shape)
            max_grad_y, max_grad_x = max_grad_index[0] + int(self.cy - self.search_radius), max_grad_index[1] + int(self.cx - self.search_radius)

            # 裁剪
            half_crop_width = self.crop_width // 2
            half_crop_height = self.crop_height // 2
            x_min = int(max_grad_x - half_crop_width)
            y_min = int(max_grad_y - half_crop_height)
            x_max = x_min + self.crop_width
            y_max = y_min + self.crop_height

            if x_min < 0 or y_min < 0 or x_max > gray_image.shape[1] or y_max > gray_image.shape[0]:
                self.get_logger().warn("Crop region out of bounds")
                return

            cropped_image = grad_magnitude[y_min:y_max, x_min:x_max]
            marked_cropped_image = cv2.cvtColor(cropped_image, cv2.COLOR_GRAY2BGR)
            cv2.circle(marked_cropped_image, (half_crop_width, half_crop_height), 5, (0, 0, 255), -1)

            # 找出梯度最高的点上方每隔5行的一行梯度最高的点
            step = 5
            points = []
            for y in range(0, half_crop_height, step):
                line = cropped_image[y, :]
                max_val_index = np.argmax(line)
                points.append((max_val_index, y))

            # 连接这些梯度最高的点
            base_point = (half_crop_width, half_crop_height)
            for point in points:
                dist = abs(point[0] - base_point[0])
                if dist <= 10:
                    cv2.line(marked_cropped_image, (base_point[0], base_point[1]), (point[0], point[1]), (0, 0, 255), 30)

            # 保存处理后的图像
            processed_file_index = self.image_count + 1
            processed_output_path = os.path.join(self.processed_images_folder, f'processed_{processed_file_index}.jpg')
            cv2.imwrite(processed_output_path, marked_cropped_image)

            # 第二次处理:识别红色物体并计算重心
            # 使用第一次处理后的中间结果
            hsv = cv2.cvtColor(marked_cropped_image, cv2.COLOR_BGR2HSV)
            lower_red = np.array([0, 100, 80])
            upper_red = np.array([10, 255, 255])
            mask = cv2.inRange(hsv, lower_red, upper_red)
            masked = cv2.bitwise_and(marked_cropped_image, marked_cropped_image, mask=mask)

            # 计算mask图像的重心
            M = cv2.moments(mask)
            if M['m00'] > 0:
                cx = int(M['m10'] / M['m00'])
                cy = int(M['m01'] / M['m00'])
                error = cx - self.crop_width // 2
                d_error = error - self.last_error

                # 控制机器人的线速度和角速度
                self.twist.linear.x = 0.11
                self.twist.angular.z = -(float(error) * 0.0011 - float(d_error) * 0.0000)
                self.last_error = error

                # 在图像上标记重心
                cv2.circle(marked_cropped_image, (cx, cy), 10, (0, 255, 0), -1)

            else:
                self.twist.linear.x = 0.0
                self.twist.angular.z = 0.0

            # 发布速度命令
            self.cmd_vel_pub.publish(self.twist)

            # 保存带有重心标记的图像
            center_marked_file_index = self.image_count + 1
            center_marked_filename = os.path.join(self.center_marked_images_folder, f'center_marked_image_{center_marked_file_index}.jpg')
            cv2.imwrite(center_marked_filename, marked_cropped_image)

            # 压缩图像并发布
            compressed_msg = self.bridge.cv2_to_compressed_imgmsg(marked_cropped_image, "jpeg")
            self.publisher.publish(compressed_msg)

            self.image_count += 1
        else:
            self.get_logger().warn("Failed to capture frame")

    def destroy_node(self):
        self.cap.release()
        self.get_logger().info("Camera released")
        super().destroy_node()


def main(args=None):
    rclpy.init(args=args)
    node = ImageProcessorAndFollower()

    try:
        rclpy.spin(node)
    except KeyboardInterrupt:
        node.get_logger().info("Shutting down")
    finally:
        node.destroy_node()
        rclpy.shutdown()


if __name__ == '__main__':
    main()

六、其他。

1. 可能用到代码-文件重命名排序代码:

import os
import re

def rename_bmp_files(folder_path):
    # 获取文件夹中所有的文件
    files = os.listdir(folder_path)
    
    # 过滤出.bmp文件
    bmp_files = [file for file in files if file.endswith('.bmp')]
    
    # 按照文件名中的数字进行排序
    def sort_key(filename):
        match = re.search(r'\d+', filename)
        if match:
            return int(match.group())
        return 0
    
    bmp_files.sort(key=sort_key)
    
    # 重新命名文件
    for index, bmp_file in enumerate(bmp_files, start=1):
        # 提取文件名和扩展名
        filename_without_ext, ext = os.path.splitext(bmp_file)
        
        # 构造新的文件名
        new_filename = f"{index}{ext}"
        # 构造完整的文件路径
        old_file = os.path.join(folder_path, bmp_file)
        new_file = os.path.join(folder_path, new_filename)
        
        # 重命名文件
        os.rename(old_file, new_file)
        print(f"Renamed '{bmp_file}' to '{new_filename}'")

# 使用示例
# 请将'your_folder_path'替换为你的文件夹路径
folder_path = 'your_folder_path'  # 假设你的.bmp文件在这个路径
rename_bmp_files(folder_path)

2. 问题-摄像头连接虚拟机,虚拟机无法识别:

     (1)首先接入摄像头,将摄像头连接入虚拟机:

     (2)输入命令 ls /dev/video* 或命令 ls /dev | grep video 可列出当前所有的视频设备文件,一般默认摄像头是 video0,可根据摄像头编号修改代码中摄像头接口内容:

     (3)打开【虚拟机→设置→USB控制器】,其中 USB2.0 和 USB3.1都可以尝试,看哪个能正常打开摄像头

;