基于YOLOV5 的ROS2功能包——快速实现目标识别功能

news2024/9/23 8:16:45

项目链接:

yolov5_ros2

运行结果如下:实时显示识别结果

一、下载功能包并运行

1. 安装依赖

首先,确保您已经更新了系统并且安装了必要的依赖。以下是一些安装步骤,其中$ROS_DISTRO 是您的ROS2发行版(例如:foxygalactic、我使用的是humble):

sudo apt update
sudo apt install python3-pip ros-$ROS_DISTRO-vision-msgs
pip3 install -i https://pypi.tuna.tsinghua.edu.cn/simple yolov5  

2. 编译和运行

编译项目并设置环境变量:

colcon build
source install/setup.bash

下面两个命令是分别用于两种不同的摄像头设备的。

  • 如果您使用的是 USB 摄像头,请运行以下命令:
sudo apt-get install ros-<ros2-distro>-usb-cam # 安装
ros2 run usb_cam usb_cam_node_exe

 如下图,此时摄像头设备正在运行并且可以通过ROS话题发布图像到/image_raw  话题上

 使用 ros2 topic list  命令来查看正在运行的话题列表

 

  • 如果您使用的是计算机上已安装的默认摄像头设备,请运行以下命令
ros2 run image_tools cam2image --ros-args -p width:=640 -p height:=480 -p frequency:=30.0 -p device_id:=0

 最后的这里 device_id:=0  或者 device_id:=-1 都可以

如下图所示,此时的话题为/image

 

此时摄像头节点会发布图像消息到ROS的话题上。因此,您可以通过订阅下面的两个话题之一来接收摄像头发布的图像消息。

/image_raw   /image

 

现在,您可以运行Yolo_ROS2节点。默认情况下,它将使用CPU来进行检测,您可以根据需要更改这些参数:

ros2 run yolov5_ros2 yolo_detect_2d --ros-args -p device:=cpu -p image_topic:=/image

此时显示,只有命令行输出

现在添加几个参数用来实时显示结果,输入下面的内容

ros2 run yolov5_ros2 yolo_detect_2d --ros-args -p device:=cpu -p image_topic:=/image -p show_result:=True -p pub_result_img:=True

显示

3. 订阅结果

Yolo_ROS2将检测结果发布到/yolo_result话题中,包括原始像素坐标以及归一化后的相机坐标系下的x和y坐标。您可以使用以下命令查看检测结果:

ros2 topic echo /yolo_result

 

4. 更进一步使用

4.1 参数设置

在运行Yolo_ROS2节点时,您可以使用 -p name:=value 的方式来修改参数值。

4.1.1 图像话题

您可以通过指定以下参数来更改图像话题:

image_topic:=/image
4.1.2 计算设备设置

如果您有CUDA支持的显卡,可以选择以下参数来配置计算设备:

device:=cpu
4.1.3 是否实时显示结果

您可以使用以下参数来控制是否实时显示检测结果。设置为True将实时显示结果,设置为False则不会显示:

show_result:=False

请注意,实时显示中的cv2.imshow可能会卡住。如果只需要验证结果,可以将此参数设置为False

4.1.4 切换不同Yolov5模型

默认情况下,Yolo_ROS2使用yolov5s模型。您可以通过以下参数来更改模型:

model:=yolov5m
4.1.5 是否发布结果图像

如果您希望Yolo_ROS2发布检测结果的图像,请使用以下参数:

pub_result_img:=True
4.1.5 相机参数文件

功能包默认从 /camera/camera_info 话题获取相机参数,在获取成功前,相机参数文件路径可以通过参数进行设置,参数为:camera_info_file,通过该参数可以设置文件路径,注意需要使用绝对目录:

-p camera_info_file:=/home/lll/ros2_ws/src/yolov5_ros2/config/camera_info.yaml

5. 问题

运行

ros2 run usb_cam usb_cam_node_exe

ros2 run yolov5_ros2 yolo_detect_2d --ros-args -p device:=cpu -p image_topic:=/image_raw -p show_result:=True -p pub_result_img:=True

报错,这个错误的原因是您的模型期望输入的通道数为 3,但实际输入的图像通道数为 2。这可能是由于图像读取或预处理过程中的问题导致的。应该是我的摄像头是2d的

二、从头写一遍代码

1.创建一个包

进入 ros2_ws/src 目录,并运行包创建命令:

ros2 pkg create --build-type ament_python ros2_yolov5

你的终端将返回一条消息,验证已创建名为 ros2_yolov5 的软件包及其所有必要的文件和文件夹。

2.编写代码

进入 ros2_ws/src/ros2_yolov5/ros2_yolov5 目录。请记住,该目录是一个与嵌套的ROS 2软件包同名的 Python包。

创建一个名为yolov5_detect_2d.py的文件,复制下面的内容

from math import frexp
from traceback import print_tb
from torch import imag
from yolov5 import YOLOv5
import rclpy
from rclpy.node import Node
from ament_index_python.packages import get_package_share_directory
from rcl_interfaces.msg import ParameterDescriptor
from vision_msgs.msg import Detection2DArray, ObjectHypothesisWithPose, Detection2D
from sensor_msgs.msg import Image, CameraInfo
from cv_bridge import CvBridge
import cv2
import yaml
from yolov5_ros2.cv_tool import px2xy
import os

# Get the ROS distribution version and set the shared directory for YoloV5 configuration files.
ros_distribution = os.environ.get("ROS_DISTRO")
package_share_directory = get_package_share_directory('yolov5_ros2')

# Create a ROS 2 Node class YoloV5Ros2.
class YoloV5Ros2(Node):
    def __init__(self):
        super().__init__('yolov5_ros2')
        self.get_logger().info(f"Current ROS 2 distribution: {ros_distribution}")

        # Declare ROS parameters.
        self.declare_parameter("device", "cuda", ParameterDescriptor(
            name="device", description="Compute device selection, default: cpu, options: cuda:0"))

        self.declare_parameter("model", "yolov5s", ParameterDescriptor(
            name="model", description="Default model selection: yolov5s"))

        self.declare_parameter("image_topic", "/image_raw", ParameterDescriptor(
            name="image_topic", description="Image topic, default: /image_raw"))
        
        self.declare_parameter("camera_info_topic", "/camera/camera_info", ParameterDescriptor(
            name="camera_info_topic", description="Camera information topic, default: /camera/camera_info"))

        # Read parameters from the camera_info topic if available, otherwise, use the file-defined parameters.
        self.declare_parameter("camera_info_file", f"{package_share_directory}/config/camera_info.yaml", ParameterDescriptor(
            name="camera_info", description=f"Camera information file path, default: {package_share_directory}/config/camera_info.yaml"))

        # Default to displaying detection results.
        self.declare_parameter("show_result", False, ParameterDescriptor(
            name="show_result", description="Whether to display detection results, default: False"))

        # Default to publishing detection result images.
        self.declare_parameter("pub_result_img", False, ParameterDescriptor(
            name="pub_result_img", description="Whether to publish detection result images, default: False"))

        # 1. Load the model.
        model_path = package_share_directory + "/config/" + self.get_parameter('model').value + ".pt"
        device = self.get_parameter('device').value
        self.yolov5 = YOLOv5(model_path=model_path, device=device)

        # 2. Create publishers.
        self.yolo_result_pub = self.create_publisher(
            Detection2DArray, "yolo_result", 10)
        self.result_msg = Detection2DArray()

        self.result_img_pub = self.create_publisher(Image, "result_img", 10)

        # 3. Create an image subscriber (subscribe to depth information for 3D cameras, load camera info for 2D cameras).
        # 首先,从ROS 2参数服务器中获取图像话题的名称和相机信息话题的名称。
        # 然后,使用这些话题名称分别创建图像订阅器和相机信息订阅器。
        # 当接收到图像消息时,调用self.image_callback方法处理图像消息。
        # 当接收到相机信息消息时,调用self.camera_info_callback方法处理相机信息消息。
        image_topic = self.get_parameter('image_topic').value
        self.image_sub = self.create_subscription(
            Image, image_topic, self.image_callback, 10)

        camera_info_topic = self.get_parameter('camera_info_topic').value
        self.camera_info_sub = self.create_subscription(
            CameraInfo, camera_info_topic, self.camera_info_callback, 1)    # 从相机信息文件中读取相机参数。

        # Get camera information.
        with open(self.get_parameter('camera_info_file').value) as f:
            self.camera_info = yaml.full_load(f.read())
            self.get_logger().info(f"default_camera_info: {self.camera_info['k']} \n {self.camera_info['d']}")

        # 4. Image format conversion (using cv_bridge).
        self.bridge = CvBridge()    # 创建一个CvBridge实例,用于图像格式转换。

        self.show_result = self.get_parameter('show_result').value
        self.pub_result_img = self.get_parameter('pub_result_img').value

    def camera_info_callback(self, msg: CameraInfo):        # 相机信息被提取并存储在camera_info字典中。这个字典被用于存储相机的内参、畸变参数等信息
        """
        Get camera parameters through a callback function.
        """
        self.camera_info['k'] = msg.k       # 相机的内参矩阵,通常是一个 3x3 的矩阵,用来描述相机的焦距和光心位置
        self.camera_info['p'] = msg.p       # 投影矩阵,是相机内参矩阵和相机的畸变参数的组合,用于将相机坐标系中的点投影到图像平面上
        self.camera_info['d'] = msg.d       # 相机的畸变参数,用来描述相机的镜头畸变情况,包括径向畸变和切向畸变
        self.camera_info['r'] = msg.r       # 重投影矩阵,用于立体视觉中的相机标定
        self.camera_info['roi'] = msg.roi   # 感兴趣区域,表示图像中感兴趣的区域的位置和大小

        self.camera_info_sub.destroy()

    def image_callback(self, msg: Image):
        # 5. Detect and publish results.
        image = self.bridge.imgmsg_to_cv2(msg)      # 将 ROS 消息转换为 OpenCV 格式的图像
        detect_result = self.yolov5.predict(image)  # 使用 YOLOv5 模型对图像进行目标检测,得到检测结果 detect_result
        self.get_logger().info(str(detect_result))

        self.result_msg.detections.clear()      # 清空了 self.result_msg 对象中的检测结果,以确保每次处理新的图像时,都能够填充最新的检测结果。
        self.result_msg.header.frame_id = "camera"
        self.result_msg.header.stamp = self.get_clock().now().to_msg()

        # Parse the results. 
        predictions = detect_result.pred[0]
        boxes = predictions[:, :4]  # x1, y1, x2, y2
        scores = predictions[:, 4]
        categories = predictions[:, 5]

        for index in range(len(categories)):
            name = detect_result.names[int(categories[index])]
            detection2d = Detection2D()
            detection2d.id = name
            x1, y1, x2, y2 = boxes[index]
            x1 = int(x1)
            y1 = int(y1)
            x2 = int(x2)
            y2 = int(y2)
            center_x = (x1+x2)/2.0
            center_y = (y1+y2)/2.0

            if ros_distribution=='galactic':
                detection2d.bbox.center.x = center_x
                detection2d.bbox.center.y = center_y
            else:
                detection2d.bbox.center.position.x = center_x
                detection2d.bbox.center.position.y = center_y

            detection2d.bbox.size_x = float(x2-x1)
            detection2d.bbox.size_y = float(y2-y1)

            obj_pose = ObjectHypothesisWithPose()
            obj_pose.hypothesis.class_id = name
            obj_pose.hypothesis.score = float(scores[index])

            # px2xy
            # px2xy 是一个函数,用于将像素坐标转换为世界坐标。在这里,将目标在图像中的中心像素
            # 坐标 (center_x, center_y) 作为参数传递给 px2xy 函数,同时传入相机的
            # 内参 self.camera_info["k"] 和畸变参数 self.camera_info["d"]。
            # world_x 和 world_y 分别是转换后的目标在相机坐标系中的世界坐标。
            world_x, world_y = px2xy(       
                [center_x, center_y], self.camera_info["k"], self.camera_info["d"], 1)
            obj_pose.pose.pose.position.x = world_x     # 将转换后的世界坐标赋值给目标在 Detection2DArray 消息中的 results 字段中的 pose
            obj_pose.pose.pose.position.y = world_y
            detection2d.results.append(obj_pose)
            self.result_msg.detections.append(detection2d)      # 将结果填充到 Detection2DArray 消息中,包括物体类别、边界框位置、置信度以及物体在相机坐标系中的位置

            # Draw results.
            if self.show_result or self.pub_result_img:         #  绘制检测结果并显示
                cv2.rectangle(image, (x1, y1), (x2, y2), (0, 255, 0), 2)
                cv2.putText(image, f"{name}({world_x:.2f},{world_y:.2f})", (x1, y1),
                            cv2.FONT_HERSHEY_SIMPLEX, 0.5, (0, 255, 0), 1)
                cv2.waitKey(1)

        # Display results if needed.
        if self.show_result:
            cv2.imshow('result', image)
            cv2.waitKey(1)

        # Publish result images if needed.
        if self.pub_result_img:         # 发布检测结果图像
            result_img_msg = self.bridge.cv2_to_imgmsg(image, encoding="bgr8")
            result_img_msg.header = msg.header
            self.result_img_pub.publish(result_img_msg)

        if len(categories) > 0:     # 如果检测到物体,就发布 Detection2DArray 消息,其中包含了所有检测到的物体信息
            self.yolo_result_pub.publish(self.result_msg)

def main():
    rclpy.init()
    rclpy.spin(YoloV5Ros2())
    rclpy.shutdown()

if __name__ == "__main__":
    main()

创建一个名为cv_tool.py的文件,复制下面的的内容

# 导入所需的库
# Import the required libraries
import cv2  # OpenCV library for image processing
import numpy as np  # NumPy library for array and matrix operations

# 相机内参矩阵K,包括相机的焦距和主点坐标
# Camera intrinsic matrix K, including camera's focal length and principal point coordinates
K = [[602.7175003324863, 0, 351.305582038406],
     [0, 601.6330312976042, 240.0929104708551],
     [0, 0, 1]]

# 相机畸变参数D,用于校正图像畸变
# Camera distortion parameters D, used for correcting image distortion
D = [0.06712174262966401, -0.2636999208734844,
     0.006484443443073637, 0.01111161327049835, 0]

# 定义一个函数px2xy,用于将像素坐标转换为相机坐标系下的二维坐标
# Define a function px2xy to convert pixel coordinates to 2D coordinates in camera coordinate system
def px2xy(point, camera_k, camera_d, z=1.0):
    # 将相机内参矩阵K和相机畸变参数D转换为NumPy数组
    # Convert camera intrinsic matrix K and camera distortion parameters D to NumPy arrays
    MK = np.array(camera_k, dtype=float).reshape(3, 3)
    MD = np.array(camera_d, dtype=float)
    
    # 将输入的像素坐标点转换为NumPy数组
    # Convert the input pixel coordinate point to a NumPy array
    point = np.array(point, dtype=float)
    
    # 使用OpenCV的cv2.undistortPoints函数对输入点进行畸变矫正,并乘以深度值z
    # Use OpenCV's cv2.undistortPoints function to correct distortion of input points and multiply by depth value z
    pts_uv = cv2.undistortPoints(point, MK, MD) * z
    
    # 返回相机坐标系下的二维坐标
    # Return 2D coordinates in the camera coordinate system
    return pts_uv[0][0]

# 调用函数并打印结果(如果需要)
# Call the function and print the result (if needed)
# print(px2xy([0, 0], K, D, 1))

3. 参数配置


package.xml中添加下面的内容

  <depend>rclpy</depend>
  <depend>vision_msgs</depend>
  <depend>yolov5</depend>

<?xml version="1.0"?>
<?xml-model href="http://download.ros.org/schema/package_format3.xsd" schematypens="http://www.w3.org/2001/XMLSchema"?>
<package format="3">
  <name>ros2_yolov5</name>
  <version>0.0.0</version>
  <description>TODO: Package description</description>
  <maintainer email="lll@todo.todo">lll</maintainer>
  <license>TODO: License declaration</license>

  <depend>rclpy</depend>
  <depend>vision_msgs</depend>
  <depend>yolov5</depend>

  <test_depend>ament_copyright</test_depend>
  <test_depend>ament_flake8</test_depend>
  <test_depend>ament_pep257</test_depend>
  <test_depend>python3-pytest</test_depend>

  <export>
    <build_type>ament_python</build_type>
  </export>
</package>

setup.py中添加 

  "yolo_detect_2d=ros2_yolov5.yolov5_detect_2d:main"

如下: 

    entry_points={
        'console_scripts': [
            "yolo_detect_2d=ros2_yolov5.yolov5_detect_2d:main"
        ],

4. 创建config文件夹

yolov5s.pt模型文件放这里,可以随意换别的模型

5. 编译运行

colcon build --packages-select ros2_yolov5

 每打开一个终端都要source遍

source install/local_setup.bash

然后再第一个终端输入

ros2 run image_tools cam2image --ros-args -p width:=640 -p height:=480 -p frequency:=30.0 -p device_id:=0

打开第二个终端输入

ros2 run ros2_yolov5 yolo_detect_2d --ros-args -p device:=cpu -p image_topic:=/image -p show_result:=True -p pub_result_img:=True

运行成功

本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若转载,请注明出处:http://www.coloradmin.cn/o/1616535.html

如若内容造成侵权/违法违规/事实不符,请联系多彩编程网进行投诉反馈,一经查实,立即删除!

相关文章

交通工程绪论

一、交通工程 交通工程学定义交通工程学研究的内容交通工程学的产生与发展交通工程学在道路运输管理中的作用 1. 交通工程学定义 早在20世纪30年代&#xff0c;美国交通工程师协会(American Institute of Traffic Engineers)给交通工程学(Traffic Engineering)下了一个定义&a…

去雾笔记-Pixel Shuffle,逆Pixel Shuffle,棋盘效应,转置卷积

文章目录 1.Pixel Shuffle2.Inverse Pixel Shuffle3.棋盘效应4.转置卷积5.宽激活块6.PSPNet7.反射填充层&#xff08;Reflective Padding Layer&#xff09;8.tanh层 1.Pixel Shuffle Pixel Shuffle是一种用于图像超分辨率的技术&#xff0c;它通过重新排列图像的像素来增加图…

Django模型的属性与方法

本节介绍Django模型的属性和方法&#xff0c;以及如何重写之前定义的模型方法等内容。 3.5.1 模型属性 Django模型中最重要的属性就是Manager&#xff0c;它是Django模型和数据库查询操作之间的接口&#xff0c;并且被用作从数据库当中获取实例的途径。如果Django模型中没有…

python基础知识一(注释、变量以及类型、类型转换)

目录 注释&#xff1a; 注释分为两种&#xff1a; 注释的作用&#xff1a; 注释的使用原则&#xff1a; 编写一段代码&#xff0c;对比一下有无注释的区别&#xff0c;以冒泡排序为例 1. 无注释版&#xff1a; 感官上是不是有点不清晰&#xff1f; 2. 有注释版&#xff1…

绝地求生:【数据流】工资杯决赛,天霸161分夺得冠军,Aixleft战神37杀

数据制作&#xff0c;整理不易&#xff0c;求支持&#xff0c;点赞&#xff0c;收藏&#xff0c;充电哟&#xff01; 如有错误&#xff0c;请在评论区指出&#xff0c;感谢各位~ 积分&#xff1a; 战队数据&#xff1a; 排名前3&#xff1a;Tian(161) / NH(145) / PeRo(137) …

linux入门到精通-第十一章-进程间通信(无名管道)

目录 参考概念**进程通信的目的&#xff1a;**Linux 操作系统支持的主要进程间通信的通信机制: 无名管道概述pipe函数建立无名管道父子进程使用无名管道通信 管道读写特点设置非阻塞的方法查看管道缓冲区命令查看管道缓冲区函数 参考 视频教程 概念 进程是一个独立的资源分配…

Oracle进阶(2)——物化视图案例延伸以及序列、同义词

一、物化视图 物化视图&#xff08;Materialized View&#xff09;是 Oracle 数据库中的一个对象&#xff0c;它是一个预先计算和存储的查询结果集&#xff0c;类似于视图&#xff0c;但与视图不同的是&#xff0c;物化视图会将查询结果保存在物理存储中&#xff0c;而不是动态…

web前端学习笔记1

前端学习笔记 1. 走进HTML 1.1 什么是HTML 超文本标记语言(英语:HyperText Markup Language,简称:HTML)是一种用于创建网页的标准标记语言。您可以使用 HTML 来建立自己的 WEB 站点,HTML 运行在浏览器上,由浏览器来解析。HTML文档的后缀名 .html.htm以上两种后缀名没有区别…

给sample_gpt加上,路的选择

灵感 鲁迅说世界上本没有路走的人多了便有了路 如何将灵感转为数学表达 首先假设a到b点有3条路吧&#xff0c;假设你只能选择一条&#xff0c;那意思就是你在训练的时候&#xff0c;只要这条路的值增大别人就会减少。 那这个思思不就是&#xff0c;3条路的值加在一起有个约束…

VMP加壳工具最新 VMProtect Ultimate v3.8.4 Build1754

网盘下载 VMProtect 软件保护功能 支持的文件和格式 VMProtect 支持32位和64位可执行文件&#xff0c;动态加载库和驱动程序。这包括屏保、Active-X 组件、BPL 库和其他 PE 格式的文件。受保护的文件基本上可在任何版本的 Windows 系统上运行&#xff0c;甚至是较早的 Windows …

仿真数据和实测数据的时频变换

目录 1.仿真数据2.实测数据3.地震信号数据4.语音数据 1.仿真数据 2.实测数据 3.地震信号数据 4.语音数据

springboot整合mybatis-puls登陆注册

目录 创建springboot项目 目录结构&#xff1a; 启动类 测试类 idea建表 pom文件 编写yml文件 qq邮箱设置 登陆注册代码 编写持久层(Dao) 注册代码 业务层 业务实现类 mapper 控制层 前端代码 注册页面 邮件正文&#xff1a; 登录代码 控制层 业务层&#…

力扣HOT100 - 101. 对称二叉树

解题思路&#xff1a; class Solution {public boolean isSymmetric(TreeNode root) {if(root null) return true;return recur(root.left, root.right);}boolean recur(TreeNode L, TreeNode R) {if (L null && R null) return true;if (L null || R null || L.…

【数学】深度学习中的概率基础知识记录

基于 Deep Learning (2017, MIT) 书总结了必要的概率知识 原blog 以及用到的Ipython notebook 文章目录 1 概述2 知识2.1 离散变量和概率质量函数&#xff08;PMF&#xff09;2.2 连续变量和概率密度函数&#xff08;PDF&#xff09;2.3 边缘概率2.4 条件概率2.5 条件概率的链式…

项目实战 | 责任链模式 (下)

案例二&#xff1a;工作流&#xff0c;费用报销审核流程 同事小贾最近刚出差回来&#xff0c;她迫不及待的就提交了费用报销的流程。根据金额不同&#xff0c;分为以下几种审核流程。报销金额低于1000元&#xff0c;三级部门管理者审批即可&#xff0c;1000到5000元除了三级部…

Docker 的基本管理

一. 云的相关知识 1. 关于云 云端服务器都有哪些提供商&#xff1a; 国内&#xff1a; 阿里云&#xff08;Alibaba Cloud&#xff09;&#xff1a; 提供ECS&#xff08;Elastic Compute Service&#xff09;弹性计算服务&#xff0c;包括通用型、计算型、内存型等多种实例…

go的编译以及运行时环境

开篇 很多语言都有自己的运行时环境&#xff0c;go自然也不例外&#xff0c;那么今天我们就来讲讲go语言的运行时环境&#xff01; 不同语言的运行时环境对比 我们都知道Java的运行时环境是jvm &#xff0c;javascript的运行时环境是浏览器内核 Java -->jvm javascript…

modelsim波形高度异常,值为X

一、问题 波形高度异常&#xff0c;忽高忽低&#xff0c;正常波形高电平和低电平是统一高度的 timescale 1ns/1nsmodule key_test_tb();//parameter define parameter CLK_PERIOD 20; parameter CNT_MAX 25d25; //仅用于仿真,对应 500nsreg sys_clk; //周期 20ns reg d; wir…

AJAX——Promise-链式调用

1.Promise链式调用 概念&#xff1a;依靠then()方法会返回一个新生成的Promise对象特性&#xff0c;继续串联下一环任务&#xff0c;知道结束 细节&#xff1a;then()回调函数中的返回值&#xff0c;会影响新生成的Promise对象最终状态和结果 好处&#xff1a;通过链式调用&…

基于 Spring Boot 博客系统开发(二)

基于 Spring Boot 博客系统开发&#xff08;二&#xff09; 本系统是简易的个人博客系统开发&#xff0c;为了更加熟练地掌握SprIng Boot 框架及相关技术的使用。&#x1f33f;&#x1f33f;&#x1f33f; 基于 Spring Boot 博客系统开发&#xff08;一&#xff09;&#x1f4…