• OpenCV之YOLOv3目标检测


    • 💂 个人主页:风间琉璃
    • 🤟 版权: 本文由【风间琉璃】原创、在CSDN首发、需要转载请联系博主
    • 💬 如果文章对你有帮助、欢迎关注、点赞、收藏(一键三连)订阅专栏

    目录

    前言

    一、预处理

    1.获取分类名

    2.获取输出层名称

    3.图像尺度变换

    二、 模型加载和执行推理

    三、输出解析

    1.网络后处理

    2.边界框绘制

    3.推理时间

    4.FPS计算


    前言

    YOLOv3(You Only Look Once version 3)是一种流行的实时目标检测算法,由Joseph Redmon等人开发。YOLOv3是YOLO系列的第三个版本,它在速度和准确性方面都取得了显著的改进,被广泛用于计算机视觉应用中的实时目标检测任务。

    详细的网络原理参考:目标检测之YOLOv1-v3_风间琉璃•的博客-CSDN博客

    参考文章:YOLOv3 – Deep Learning Based Object Detection – YOLOv3 with OpenCV ( Python / C++ )

     YOLOv3模型及配置文件下载

    weights: https://pjreddie.com/media/files/yolov3.weights

    cfg:https://github.com/pjreddie/darknet/blob/master/cfg/yolov3.cfg

    coco: https://github.com/pjreddie/darknet/blob/master/data/coco.names

    一、预处理

    1.获取分类名

    数据集采用的coco数据集,需要将coco.names包含训练模型的所有类名称加载到内存中。

    1. vector classes; //标签名
    2. string classespath = "F:/data/CQU/VS/YOLOv3/coco.names";
    3. //得到网络对应的标签
    4. void getclasses(string classespath )
    5. {
    6. ifstream ifs(classespath);
    7. string line;
    8. while (getline(ifs, line))
    9. {
    10. classes.push_back(line);
    11. }
    12. }

    最暴力的方法也可以打开文件,直接类别定义一个80个类别的变量存储。 

    2.获取输出层名称

    YOLOv3由于可以在三个不同的尺度上进行检测,也就是说它有三个输出层,因此我们需要获得它的三个输出层的名称。

    1. //得到网络输出层的名称,yolov3有3种尺度输出
    2. vector getOutputsNames(const Net& net)
    3. {
    4. #if 1
    5. //直接获取模型中未连接到任何后续层的输出层的名称,一般是yolov3是三层输出层
    6. std::vector names = net.getUnconnectedOutLayersNames();
    7. //for (int i = 0; i < names.size(); i++)
    8. //{
    9. //printf("output layer name : %s\n", names[i].c_str());
    10. //}
    11. return names;
    12. #elif 0
    13. static vector names;
    14. if (names.empty())
    15. {
    16. //获取模型中未连接到任何后续层的输出层的索引,一般是yolov3是三层输出层
    17. vector<int> outLayers = net.getUnconnectedOutLayers();
    18. //获取模型中的所有层的名称
    19. vector layersNames = net.getLayerNames();
    20. names.resize(outLayers.size());
    21. for (size_t i = 0; i < outLayers.size(); ++i)
    22. {
    23. names[i] = layersNames[outLayers[i] - 1];
    24. }
    25. }
    26. return names;
    27. #endif
    28. }

    这里提供了两种方法,直接获取三层输出层的名称可以使用 getUnconnectedOutLayersNames(),也可以先获取三个输出层的索引,然后通过getLayerNames获得整个网络层的名称,然后再通过索引获取最后输出三层的名称。

    3.图像尺度变换

    神经网络的输入图像需要采用称为blob的特定格式。从输入图像或视频流中读取帧后,将通过blobFromImage函数将其转换为神经网络的输入blob。

    在此过程中,它使用比例因子1/255将图像像素值缩放到0到1的目标范围。它还将图像的大小调整为给定大小(416,416)而不进行裁剪。请注意,不在此处执行任何均值减法,因此将[0,0,0]传递给函数的mean参数,swapRB参数设置为true(即交换R和B),这里个人认为交换与否问题应该不大,看网上大佬的都写的交换。

    1. int width = 416;
    2. int height = 416;
    3. //图像预处理
    4. blobFromImage(frame, blob, 1 / 255.0, cv::Size(width, height), Scalar(0, 0, 0), true, false);

    二、 模型加载和执行推理

    加载网络直接使用readNetFromDarknet或者readNet都行。

    1. String config = "F:/data/CQU/VS/YOLOv3/yolov3.cfg";
    2. String weights = "F:/data/CQU/VS/YOLOv3/yolov3.weights";
    3. //加载yolo网络模型
    4. Net net = readNetFromDarknet(config, weights);

    这里可以根据个人的情况设置是否使用CUDA加速,我编译过CUDA的OpenCV,所以这里利用第二种方式

    1. #if 0
    2. //cpu推理
    3. net.setPreferableBackend(DNN_BACKEND_OPENCV);
    4. net.setPreferableTarget(DNN_TARGET_CPU);
    5. #elif 1
    6. //使用cuda加速
    7. net.setPreferableBackend(cv::dnn::DNN_BACKEND_CUDA);
    8. net.setPreferableTarget(cv::dnn::DNN_TARGET_CUDA_FP16);
    9. #endif

     预处理和网络模型都加载好了就可以进行图像的预测了

    1. //设置网络的输入
    2. net.setInput(blob);
    3. //执行推理,这里有多个输出层,好像不能使用 Mat outsnet.forward(outs, getOutputsNames(net));进行推理
    4. //大概率是因为这里输出层是三层
    5. vector outs;
    6. net.forward(outs, getOutputsNames(net));

    预测的结果保存在outs的Mat类型矩阵中。接下来就需要对这个预测结果进行后处理。 

    三、输出解析

    1.网络后处理

    网络输出的每个边界框都分别由一个包含着类别名字和5个元素的向量表示。前四个元素代表center_x, center_y, width和height。第五个元素表示包含着目标的边界框的置信度

    其余的元素是和每个类别(如目标种类)有关的置信度。边界框分配给最高分数对应的那一种类。一个边界框的最高分数也叫做它的置信度(confidence)。如果边界框的置信度低于规定的阀值,算法上不再处理这个边界框。

    置信度大于或等于置信度阀值的边界框,将进行非最大抑制。这会减少重叠的边界框数目。

    1. float confThreshold = 0.5; //置信度
    2. float nmsThreshold = 0.4; // nms阈值
    3. //使用非极大值抑制NMS来删除置信度较低的边界框
    4. void postprocess(Mat& frame, const vector& outs)
    5. {
    6. //对象的类别标签索引
    7. vector<int> classIds;
    8. //目标检测的置信度
    9. vector<float> confidences;
    10. //目标检测的边界框信息
    11. vector boxes;
    12. //对三层输出层分别处理
    13. for (int i = 0; i < outs.size(); i++)
    14. {
    15. //遍历来自网络的所有边界框,并仅保留具有高置信度分数的边界框,将框的类别标签分配为具有该框上最高分数的类别
    16. float* data = (float*)outs[i].data;
    17. //对某一层处理,输出包含5个元素:x,y,w,h,置信度
    18. for (int j = 0; j < outs[i].rows; ++j, data += outs[i].cols)
    19. {
    20. //获取置信度
    21. Mat scores = outs[i].row(j).colRange(5, outs[i].cols);
    22. Point classIdPoint;
    23. double confidence; //最大置信度
    24. //获取置信度最大的数值和位置
    25. minMaxLoc(scores, 0, &confidence, 0, &classIdPoint);
    26. if (confidence > confThreshold)
    27. {
    28. int centerX = (int)(data[0] * frame.cols);
    29. int centerY = (int)(data[1] * frame.rows);
    30. int width = (int)(data[2] * frame.cols);
    31. int height = (int)(data[3] * frame.rows);
    32. int left = centerX - width / 2;
    33. int top = centerY - height / 2;
    34. classIds.push_back(classIdPoint.x);
    35. confidences.push_back((float)confidence);
    36. boxes.push_back(Rect(left, top, width, height));
    37. }
    38. }
    39. }
    40. //非极大值抑制以消除重叠度较高且置信度较低的边界框
    41. vector<int> indices; //NMSBoxes输出,包含经过非最大值抑制后所选的边界框的索引
    42. NMSBoxes(boxes, confidences, confThreshold, nmsThreshold, indices);
    43. for (size_t i = 0; i < indices.size(); ++i)
    44. {
    45. int idx = indices[i];
    46. Rect box = boxes[idx];
    47. //在图像上绘制检测结果,包括类别标签、置信度和边界框
    48. drawPred(classIds[idx], confidences[idx], box.x, box.y,box.x + box.width, box.y + box.height, frame);
    49. }
    50. }

    非极大值抑制由参数nmsThreshold控制。如果nmsThreshold设置太少,比如0.1,我们可能检测不到相同或不同种类的重叠目标。如果设置得太高,比如1,可能出现一个目标有多个边界框包围。

    NMSBoxes 是一个用于执行非极大值抑制(Non-Maximum Suppression,简称NMS)的函数,通常在目标检测和边界框预测等计算机视觉任务中使用。NMS 的目标是减少一组重叠的边界框,以便仅保留最相关的边界框,从而提高检测或识别性能。 

    函数原型:

    void cv::dnn::NMSBoxes(const std::vector& bboxes, const std::vector<float>& scores, float score_threshold, float nms_threshold, std::vector<int>& indices);
    

    ①bboxes:表示输入的边界框列表。每个边界框通常由 cv::Rect 类型表示,其中包含了左上角和右下角的坐标。这个参数用于传递待执行非极大值抑制的边界框列表。

    ②scores:包含与每个边界框相关的置信度或分数。这些分数用于度量边界框包含的对象的可信度或重要性。

    ③score_threshold:用于指定一个分数阈值。只有分数高于或等于此阈值的边界框才会被考虑进行非极大值抑制。

    ④nms_threshold:表示在执行非极大值抑制时两个边界框之间的IoU(交并比)阈值。如果两个边界框的IoU大于等于此阈值,则其中一个边界框将被抑制

    ⑤indices用于返回经过非极大值抑制后保留的边界框的索引列表。这些索引对应于输入边界框列表中的边界框,只有这些边界框被保留。

    该函数的主要作用是根据分数和IoU阈值对输入的边界框列表进行非极大值抑制,然后将保留的边界框的索引存储在 indices 中,以供后续处理使用 。

    2.边界框绘制

    边界框的绘制就是根据将网络的输出结果经过非极大值抑制后的边界框,绘制在原图,并指定类别标签和置信度分数。

    1. //画预测的目标bounding box
    2. void drawPred(int classId, float conf, int left, int top, int right, int bottom, Mat& frame)
    3. {
    4. //绘制检测目标的矩形框
    5. rectangle(frame, Point(left, top), Point(right, bottom), Scalar(255, 178, 50), 2);
    6. //获取类别名称及其置信度
    7. string label = format("%.2f", conf);
    8. if (!classes.empty())
    9. {
    10. CV_Assert(classId < (int)classes.size());
    11. label = classes[classId] + ":" + label;
    12. }
    13. //在边界框的顶部显示标签
    14. int baseLine;
    15. Size labelSize = getTextSize(label, FONT_HERSHEY_SIMPLEX, 0.5, 1, &baseLine);
    16. //确保顶部位置足够高以容纳标签
    17. top = max(top, labelSize.height);
    18. rectangle(frame, Point(left, top - round(1.5 * labelSize.height)), Point(left + round(1.5 * labelSize.width), top + baseLine), Scalar(255, 255, 255), FILLED);
    19. putText(frame, label, Point(left, top), FONT_HERSHEY_SIMPLEX, 0.5, Scalar(0, 255, 0), 1);
    20. }

    3.推理时间

    使用OpenCV dnn进行推理,可以测量推理的时间。主要是使用getPerfProfile函数。其函数原型如下:

    cv::Mat cv::dnn::Net::getPerfProfile(std::vector<double>& timings) const;
    

    timings(输出参数):用于存储每个网络层的推理时间。这是一个输出参数,getPerfProfile 会填充这个向量,使您能够获取每个层的执行时间信息。 

    getPerfProfile 函数的主要作用是分析深度学习模型的性能,并返回每个网络层的推理时间。让利用freq 将时间从s转换为ms。

    1. //函数getPerfProfile返回推理的总时间(t)以及每个层的时间(在layersTimes中)
    2. //存储每一次推理时间
    3. vector<double> layersTimes;
    4. //getTickFrequency() 返回时钟频率,通过除以1000,freq将其转换为毫秒,将时间单位从秒(s)转换为毫秒(ms)的缩放因子
    5. double freq = getTickFrequency() / 1000;
    6. //获取推理总时间,并转换将时间单位转换为毫秒
    7. double t = net.getPerfProfile(layersTimes) / freq;
    8. string label = format("Inference time: %.2fms", t);
    9. putText(frame, label, Point(0, 15), FONT_HERSHEY_SIMPLEX, 0.5, Scalar(0, 0, 255));

    4.FPS计算

    在网络推理(如深度学习模型的推理)中,帧率(Frames Per Second,FPS)是一项关键的性能指标,用于表示每秒处理的图像帧数量。通常,帧率越高,系统性能越好。

    在图像进行目标检测之前加入下面一句代码,获取当前系统的计时周期

    1. //获得当前系统的计时间周期数,求FPS
    2. double t = (double)getTickCount();

     然后在图像目标检测完加入下面的代码,计算FPS并显示

    1. //FPS计算
    2. t = ((double)getTickCount() - t) / getTickFrequency();//求输入帧后经过的周期数/每秒系统计的周期数=一帧用时多少秒
    3. double fps = 1.0 / t;//求倒数得到每秒经过多少帧,即帧率
    4. string text = format("FPS:%.2f",fps);
    5. cv::putText(frame, text, Point(10, 50), FONT_HERSHEY_SIMPLEX, 1, cv::Scalar(0, 255, 0), 2, 8, 0);

    运行结果:

    YOLOv3 C++

    程序源码:下载链接:https://download.csdn.net/download/qq_53144843/88350694
     

    1. // YOLOv3.cpp : 此文件包含 "main" 函数。程序执行将在此处开始并结束。
    2. //
    3. #include
    4. #include
    5. #include
    6. #include
    7. #include
    8. #include
    9. using namespace cv;
    10. using namespace cv::dnn;
    11. using namespace std;
    12. //初始化参数
    13. float confThreshold = 0.5; //置信度
    14. float nmsThreshold = 0.4; // nms阈值
    15. int width = 416;
    16. int height = 416;
    17. vector classes; //标签名
    18. //得到网络输出层名称
    19. vector getOutputsNames(const Net& net);
    20. //绘制bounding box
    21. void drawPred(int classId, float conf, int left, int top, int right, int bottom, Mat& frame);
    22. //解析输出
    23. void postprocess(Mat& frame, const vector& outs);
    24. //图片检测
    25. void detect_image(string image_path, string config, string weights, string classespath);
    26. //目标检测
    27. void detect_video(string video_path, string config, string weights, string classespath);
    28. int main()
    29. {
    30. String config = "F:/data/CQU/VS/YOLOv3/yolov3.cfg";
    31. String weights = "F:/data/CQU/VS/YOLOv3/yolov3.weights";
    32. string classespath = "F:/data/CQU/VS/YOLOv3/coco.names";
    33. string image_path = "F:/data/CQU/VS/YOLOv3/3.jpg";
    34. string video_path = "F:/data/CQU/VS/YOLOv3/car.mp4";
    35. #if 0
    36. detect_image(image_path, config, weights, classespath);
    37. #elif 1
    38. detect_video(video_path, config, weights, classespath);
    39. #endif
    40. return 0;
    41. }
    42. //得到网络对应的标签
    43. void getclasses(string classespath )
    44. {
    45. ifstream ifs(classespath);
    46. string line;
    47. while (getline(ifs, line))
    48. {
    49. classes.push_back(line);
    50. }
    51. }
    52. //得到网络输出层的名称,yolov3有3种尺度输出
    53. vector getOutputsNames(const Net& net)
    54. {
    55. #if 1
    56. //直接获取模型中未连接到任何后续层的输出层的名称,一般是yolov3是三层输出层
    57. std::vector names = net.getUnconnectedOutLayersNames();
    58. //for (int i = 0; i < names.size(); i++)
    59. //{
    60. //printf("output layer name : %s\n", names[i].c_str());
    61. //}
    62. return names;
    63. #elif 0
    64. static vector names;
    65. if (names.empty())
    66. {
    67. //获取模型中未连接到任何后续层的输出层的索引,一般是yolov3是三层输出层
    68. vector<int> outLayers = net.getUnconnectedOutLayers();
    69. //获取模型中的所有层的名称
    70. vector layersNames = net.getLayerNames();
    71. names.resize(outLayers.size());
    72. for (size_t i = 0; i < outLayers.size(); ++i)
    73. {
    74. names[i] = layersNames[outLayers[i] - 1];
    75. }
    76. }
    77. return names;
    78. #endif
    79. }
    80. //画预测的目标bounding box
    81. void drawPred(int classId, float conf, int left, int top, int right, int bottom, Mat& frame)
    82. {
    83. //绘制检测目标的矩形框
    84. rectangle(frame, Point(left, top), Point(right, bottom), Scalar(255, 178, 50), 2);
    85. //获取类别名称及其置信度
    86. string label = format("%.2f", conf);
    87. if (!classes.empty())
    88. {
    89. CV_Assert(classId < (int)classes.size());
    90. label = classes[classId] + ":" + label;
    91. }
    92. //在边界框的顶部显示标签
    93. int baseLine;
    94. Size labelSize = getTextSize(label, FONT_HERSHEY_SIMPLEX, 0.5, 1, &baseLine);
    95. //确保顶部位置足够高以容纳标签
    96. top = max(top, labelSize.height);
    97. rectangle(frame, Point(left, top - round(1.5 * labelSize.height)), Point(left + round(1.5 * labelSize.width), top + baseLine), Scalar(255, 255, 255), FILLED);
    98. putText(frame, label, Point(left, top), FONT_HERSHEY_SIMPLEX, 0.5, Scalar(0, 255, 0), 1);
    99. }
    100. //使用非极大值抑制NMS来删除置信度较低的边界框
    101. void postprocess(Mat& frame, const vector& outs)
    102. {
    103. //对象的类别标签索引
    104. vector<int> classIds;
    105. //目标检测的置信度
    106. vector<float> confidences;
    107. //目标检测的边界框信息
    108. vector boxes;
    109. //对三层输出层分别处理
    110. for (int i = 0; i < outs.size(); i++)
    111. {
    112. //遍历来自网络的所有边界框,并仅保留具有高置信度分数的边界框,将框的类别标签分配为具有该框上最高分数的类别
    113. float* data = (float*)outs[i].data;
    114. //对某一层处理,输出包含5个元素:x,y,w,h,置信度
    115. for (int j = 0; j < outs[i].rows; ++j, data += outs[i].cols)
    116. {
    117. //获取置信度
    118. Mat scores = outs[i].row(j).colRange(5, outs[i].cols);
    119. Point classIdPoint;
    120. double confidence; //最大置信度
    121. //获取置信度最大的数值和位置
    122. minMaxLoc(scores, 0, &confidence, 0, &classIdPoint);
    123. if (confidence > confThreshold)
    124. {
    125. int centerX = (int)(data[0] * frame.cols);
    126. int centerY = (int)(data[1] * frame.rows);
    127. int width = (int)(data[2] * frame.cols);
    128. int height = (int)(data[3] * frame.rows);
    129. int left = centerX - width / 2;
    130. int top = centerY - height / 2;
    131. classIds.push_back(classIdPoint.x);
    132. confidences.push_back((float)confidence);
    133. boxes.push_back(Rect(left, top, width, height));
    134. }
    135. }
    136. }
    137. //非极大值抑制以消除重叠度较高且置信度较低的边界框
    138. vector<int> indices; //NMSBoxes输出,包含经过非最大值抑制后所选的边界框的索引
    139. NMSBoxes(boxes, confidences, confThreshold, nmsThreshold, indices);
    140. for (size_t i = 0; i < indices.size(); ++i)
    141. {
    142. int idx = indices[i];
    143. Rect box = boxes[idx];
    144. //在图像上绘制检测结果,包括类别标签、置信度和边界框
    145. drawPred(classIds[idx], confidences[idx], box.x, box.y,box.x + box.width, box.y + box.height, frame);
    146. }
    147. }
    148. void detect_image(string image_path, string config, string weights, string classespath)
    149. {
    150. //加载目标检测标签
    151. getclasses(classespath);
    152. //加载yolo网络模型
    153. Net net = readNetFromDarknet(config, weights);
    154. #if 0
    155. //cpu推理
    156. net.setPreferableBackend(DNN_BACKEND_OPENCV);
    157. net.setPreferableTarget(DNN_TARGET_CPU);
    158. #elif 1
    159. //使用cuda加速
    160. net.setPreferableBackend(cv::dnn::DNN_BACKEND_CUDA);
    161. net.setPreferableTarget(cv::dnn::DNN_TARGET_CUDA_FP16);
    162. #endif
    163. cv::Mat frame = cv::imread(image_path);
    164. //图片预处理
    165. Mat blob = blobFromImage(frame, 1 / 255.0, cv::Size(width, height), Scalar(0, 0, 0), true, false);
    166. //设置网络的输入
    167. net.setInput(blob);
    168. //执行推理,这里有多个输出层,好像不能使用 Mat outsnet.forward(outs, getOutputsNames(net));进行推理
    169. //大概率是因为这里输出层是三层
    170. vector outs;
    171. net.forward(outs, getOutputsNames(net));
    172. //剔除置信度较小的 bounding boxes
    173. postprocess(frame, outs);
    174. //函数getPerfProfile返回推理的总时间(t)以及每个层的时间(在layersTimes中)
    175. //存储每一次推理时间
    176. vector<double> layersTimes;
    177. //getTickFrequency() 返回时钟频率,通过除以1000,freq将其转换为毫秒,将时间单位从秒(s)转换为毫秒(ms)的缩放因子
    178. double freq = getTickFrequency() / 1000;
    179. //获取推理总时间,并转换将时间单位转换为毫秒
    180. double t = net.getPerfProfile(layersTimes) / freq;
    181. string label = format("Inference time: %.2fms", t);
    182. //putText(frame, label, Point(0, 15), FONT_HERSHEY_SIMPLEX, 0.5, Scalar(0, 0, 255));
    183. imshow("YOLOv3", frame);
    184. cv::waitKey(0);
    185. }
    186. void detect_video(string video_path, string config, string weights, string classespath)
    187. {
    188. //加载目标检测标签
    189. getclasses(classespath);
    190. //加载网络
    191. Net net = readNetFromDarknet(config, weights);
    192. #if 0
    193. //cpu推理
    194. net.setPreferableBackend(DNN_BACKEND_OPENCV);
    195. net.setPreferableTarget(DNN_TARGET_CPU);
    196. #elif 1
    197. //使用cuda加速
    198. net.setPreferableBackend(cv::dnn::DNN_BACKEND_CUDA);
    199. net.setPreferableTarget(cv::dnn::DNN_TARGET_CUDA_FP16);
    200. #endif
    201. // 打开视频流,使用视频文件的路径. 0表示默认摄像头
    202. VideoCapture capture(video_path);
    203. // 检查是否成功打开视频流
    204. if(!capture.isOpened())
    205. {
    206. std::cerr << "Error: Could not open video stream." << std::endl;
    207. }
    208. Mat frame, blob;
    209. //处理每一帧
    210. while(capture.read(frame))
    211. {
    212. //图像预处理
    213. blobFromImage(frame, blob, 1 / 255.0, cv::Size(width, height), Scalar(0, 0, 0), true, false);
    214. //设置网络的输入
    215. net.setInput(blob);
    216. //获得当前系统的计时间周期数,求FPS
    217. double t = (double)getTickCount();
    218. //执行推理
    219. vector outs;
    220. net.forward(outs, getOutputsNames(net));
    221. //剔除置信度较小的 bounding boxes
    222. postprocess(frame, outs);
    223. //将图像帧转换为CV_8U格式显示
    224. Mat detectedFrame;
    225. frame.convertTo(detectedFrame, CV_8U);
    226. //FPS计算
    227. t = ((double)getTickCount() - t) / getTickFrequency();//求输入帧后经过的周期数/每秒系统计的周期数=一帧用时多少秒
    228. double fps = 1.0 / t;//求倒数得到每秒经过多少帧,即帧率
    229. string text = format("FPS:%.2f",fps);
    230. cv::putText(frame, text, Point(10, 50), FONT_HERSHEY_SIMPLEX, 1, cv::Scalar(0, 255, 0), 2, 8, 0);
    231. imshow("YOLOv3", frame);
    232. int c = waitKey(1);
    233. if (c == 27)
    234. {
    235. break;
    236. }
    237. }
    238. capture.release();
    239. waitKey(0);
    240. }

    结束语
    感谢你观看我的文章呐~本次航班到这里就结束啦 🛬

    希望本篇文章有对你带来帮助 🎉,有学习到一点知识~

    躲起来的星星🍥也在努力发光,你也要努力加油(让我们一起努力叭)。

    最后,博主要一下你们的三连呀(点赞、评论、收藏),不要钱的还是可以搞一搞的嘛~

    不知道评论啥的,即使扣个666也是对博主的鼓舞吖 💞 感谢 💐

  • 相关阅读:
    JVM 系列(4)一看就懂的对象内存布局
    【车载开发系列】CAN总线知识进阶篇
    linux 多台机器修改时间同步
    浅谈测试需求分析
    SpringSecurity系列一:05 SpringSecurity 自定义表单登录和注销登录认证
    2023年全国职业院校技能大赛信息安全管理与评估网络安全渗透任务书
    3.5 讲一讲关于小红书的搜索引流技巧【玩赚小红书】
    YOLOv5、YOLOv8改进:Decoupled Head解耦头
    微服务项目:尚融宝(59)(核心业务流程:提现和还款(2))
    网络原理之 TCP解释超详细!!!
  • 原文地址:https://blog.csdn.net/qq_53144843/article/details/132948310