5200 字
26 分钟
【电赛】01 - 2023E题运动控制与目标追踪(视觉部分)

注意#

本项目创建时间:2024-7-17

本文标记为OpenCV入门参考项目以及电赛典型范例,由题主长期维护 —2024-10-2

一、图像处理方案#

Opencv: 开源计算机视觉处理库,使用嵌入式SOC板卡作为摄像头模块,提供相对可靠的性能。

由于激光点的亮度远高于其他位置,所以采用HSV格式的色彩空间,对图像H,S,V域进行识别。

若要区分红绿激光,那么就对图像的RGB空间进行处理

2. 电工胶带的识别#

  • 使用原图像->灰度图像->设置阈值后进行二值化的方法,使得黑胶带区域识别为纯白色,其余部分全部为纯黑色区域。关于调参:建议使用创建滑块窗口的方法,进行动态调参,减少调参上浪费的时间。

  • 摄像头容易受到外接环境光的干扰,可以通过调节分辨率,设置摄像头的图像获取范围,也有一种更为实用的方法:ROI(感兴趣区),可以通过创建掩膜的方式,只对ROI区域进行处理,这样就排除了外界环境的干扰。当然也可以使用切片的方法,定义一个变量用于读取图像的指定范围,然后再进行图像处理,需要显示参数时,再将一些特征点、辅助线、坐标点、数据显示在想要观看的图像上,使用opencv中的imshow进行可视化显示(注意:imshow是非常耗时的操作,在调试时可以使用,在最终运行时建议注释掉,提升程序执行效率,或者采取图像编解码的方法,使用TCP/UDP协议,ROS通信等方式进行远程图传,降低软件资源的使用)

  • ROI区域也不一定是适宜的图像处理区域,也可能有一些噪声信号的干扰,这可能就要需要用到一些滤波处理的算法,如:高斯平滑滤波(高斯模糊)。

  • 胶带容易受激光点的影响,导致二值化图像被“分割”,所以需要对胶带的二值化图像先膨胀,后进行图形学闭运算缝合胶带二值化图像漏洞

3. 串口通信的关键问题#

  • 数据帧:一开始采用字符串发送地方法。结果单片机很容易接收不到一些数据帧
  • 通信速率:19200

3. 激光“巡线”的路径规划#

  • 第一种方法:程序不断对图像进行处理,边缘检测,封闭图形检测,然后激光沿着检测出来的图形边沿进行巡线

  • 第二种方法:采用“路径规划的方法”,进行一定时间的图像采集后,确定好激光的运行路径,这样,即避免了激光对矩形框识别的强光干扰,又大大减少了程序运行的资源损耗,只需要执行激光跟随部分的程序,不需要再对图像进行边沿检测。

二、程序设计#

视觉部分#

1. 摄像头处理部分#

1.1 摄像头的类封装#

GuideLine 的作用:绘制辅助线

思考:Python中函数对形参的调用,会不会影响到实参?

# 摄像头类创建
class pi_Camera():
# 类的初始化
def __init__(self):
# 图像初始化配置
self.Video = cv2.VideoCapture(8, cv2.CAP_V4L2)# 使能摄像头8的驱动
# 检查摄像头是否打开
ret = self.Video.isOpened()
if ret:
print("The video is opened.")
else:
print("No video.")
codec = cv2.VideoWriter_fourcc('M', 'J', 'P', 'G')
self.Video.set(cv2.CAP_PROP_FOURCC, codec)
self.Video.set(cv2.CAP_PROP_FPS, 60) # 帧数
self.Video.set(cv2.CAP_PROP_FRAME_WIDTH, 640) # 列 宽度
self.Video.set(cv2.CAP_PROP_FRAME_HEIGHT, 480) # 行 高度
# 绘制辅助线
def GuideLine(self, c1, c2):
ret, image = self.Video.read()# 注意:read返回一个bull值和图像数据list!,需要用两个变量获取
if ret:
cv2.line(image, (0, 360), (640, 360), color=(0, 0, 255), thickness=3)# 红色的线
cv2.line(image, (0, 240), (640, 240), color=(0, 0, 255), thickness=3)# 红色的线
cv2.line(image, (int(c1), 360), (int(c2), 240), color=(0, 255, 0), thickness=2)# 绘出倾角线
cv2.imshow("GuideLine", image)

1.2 摄像头的图像获取#

ret, frame = Watch.Video.read() #获取摄像头图像数据

1.3 相机资源的释放#

思考:Python中不对窗口,或者使用Python语言的硬件资源不释放会怎么样?

Watch.Video.release()

2. 激光追踪相关#

要想实现激光点的巡线,以及红绿激光的相互跟随,首先要做到单个激光点的精确识别与控制移动,这里我们使用了二维舵机云台,精度在能够完成基本要求之内。使用USB 1080P摄像头进行画面捕获。

激光点在图像中的亮度远远大于其他图像像素点,不难想到在HSV空间中对图像进行操作更容易识别到激光点。

2.1 激光点识别的图形预处理#

通过调用v2.cvtColor()函数的方法,将图像颜色空间转换到 HSV中,然后通过设定阈值,进行图像的二值化(阈值调节建议采用滑块创建的方法,并且函数需要赋初值)

# 寻找激光点
def detect_lasers(image):
# imgae:用于处理的画布图像,frame:原图像,且是最终数据展示的图像
global load_process# 声明使用外部定义的 load_process
hsv = cv2.cvtColor(image, cv2.COLOR_BGR2HSV)# 转换颜色空间为HSV
# 定义红色激光点hsv色彩阈值范围,用作二值化
lower_laser = np.array([0, 100, 180])
upper_laser = np.array([179, 255, 255])
# 创建二值化图像
mask_laser = cv2.inRange(hsv, lower_laser, upper_laser)
# cv2.imshow("mask_laser1", mask_laser)
# 闭运算
kernel = np.ones((5, 5), np.uint8)
mask_laser = cv2.morphologyEx(mask_laser, cv2.MORPH_CLOSE, kernel)

2. 2 激光点的轮廓识别#

调用cv2.findContours()函数,对封闭图形进行检测,并且绘制出激光点的图形识别轮廓

函数解释:

  1. mask_laser:这是输入的二值图像,也就是说,它应该是经过阈值处理或其他形式的图像处理后得到的黑白图像(通常是单通道的,只有 0 和 255 两个像素值)。
  2. cv2.RETR_EXTERNAL:这是轮廓检索模式(Contour Retrieval Mode),指定了轮廓的检索模式。RETR_EXTERNAL 表示只检索最外层的轮廓,忽略内部的轮廓。还有其他的检索模式,比如 RETR_LISTRETR_TREE 等,可以根据需要选择不同的模式。
  3. cv2.CHAIN_APPROX_SIMPLE:这是轮廓的逼近方法(Contour Approximation Method),指定了轮廓的表示方法。CHAIN_APPROX_SIMPLE 表示压缩水平、垂直和对
  4. 第一个返回值 contours_laser 是一个列表,其中每个元素是一个 numpy 数组,表示一个检测到的轮廓

cv2.minAreaRect()

函数解释:

  • contour:这是一个轮廓,通常是通过 findContours 函数找到的其中一个轮廓对象,它是一个包含轮廓点的 numpy 数组。
  • cv2.minAreaRect(contour):这个函数接收一个轮廓作为输入,并返回一个 RotatedRect 对象,它表示包围轮廓的最小旋转矩形框。

具体来说,返回的 RotatedRect 对象包含以下信息:

  • center:矩形框的中心点坐标 (cx, cy)
  • size:矩形框的宽度和高度 (width, height)
  • angle:矩形框相对于水平方向的旋转角度(逆时针为正)

获取矩形框角点:

  1. box = cv2.boxPoints(rect)
    • cv2.boxPoints 函数接收一个 RotatedRect 对象作为输入,并返回该旋转矩形框的四个顶点坐标。
    • 这些顶点坐标是浮点型数据,表示为一个包含四个点的 numpy 数组。
  2. box = np.int0(box)
    • 这一步将上一步得到的四个顶点坐标 box 转换为整数类型的 numpy 数组。
    • np.int0() 函数会将浮点数数组四舍五入为最接近的整数,并返回一个整数类型的 numpy 数组。
# 寻找轮廓
contours_laser, _ = cv2.findContours(mask_laser, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE)
laser_coords = None
for contour in contours_laser:
rect = cv2.minAreaRect(contour)# 获取最小矩形框
laser_coords = tuple(map(int, rect[0]))# 矩形框的中心坐标
box = cv2.boxPoints(rect)# 矩形框的四个角点
box = np.int0(box)
cv2.drawContours(frame, [box], 0, (0, 0, 0), 2)# 绘制矩形框(原图像中)

2.3激光点的颜色区分#

imge.shape[]

  • image.shape:这是一个 numpy 数组属性,用于获取图像的尺寸和通道数信息。
  • image.shape[:2]:这是对 shape 属性进行切片操作,[:2] 表示取前两个元素,即图像的高度和宽度。

具体来说,如果 image 是一个图像的 numpy 数组,例如形状为 (height, width, channels),那么 image.shape[:2] 将返回一个包含图像高度和宽度的元组 (height, width)

这种操作通常用于获取图像的空间尺寸信息,以便在处理图像时进行尺寸相关的计算或操作

方圆坐标确定:

  1. 获取输入坐标:
    • x, y = coords:这行代码将变量 coords 解构为 xy,分别表示中心点的横纵坐标。
  2. 确定左上角坐标:
    • x_start = max(0, x - radius):计算方形区域的左上角横坐标。x - radius 表示以中心点 x 为基础,向左偏移半径 radius 的距离。max(0, ...) 确保左上角横坐标不会小于 0,即不会超出图像左边界。
    • y_start = max(0, y - radius):计算方形区域的左上角纵坐标。同理,y - radius 表示以中心点 y 为基础,向上偏移半径 radius 的距离。max(0, ...) 确保左上角纵坐标不会小于 0,即不会超出图像上边界。
  3. 确定右下角坐标:
    • x_end = min(width - 1, x + radius):计算方形区域的右下角横坐标。x + radius 表示以中心点 x 为基础,向右偏移半径 radius 的距离。min(width - 1, ...) 确保右下角横坐标不会超过图像的右边界。
    • y_end = min(height - 1, y + radius):计算方形区域的右下角纵坐标。同理,y + radius 表示以中心点 y 为基础,向下偏移半径 radius 的距离。min(height - 1, ...) 确保右下角纵坐标不会超过图像的下边界。

get_pixel_sum

这段代码用于从给定的区域中提取红色(R)和绿色(G)颜色通道的值。让我来解释一下这段代码的含义:

  1. roi[:, :, 2]
    • roi 应该是图像的一个区域,即感兴趣的区域(Region of Interest,ROI)。
    • : 是一个表示所有行或所有列的切片操作。
    • [ : , : , 2] 表示选取所有行的所有列的第三个颜色通道。在 BGR 颜色空间中,第三个通道对应的是红色通道,所以这行代码获取了 ROI 中所有的红色通道值。
  2. roi[:, :, 1]
    • 与上面的代码类似,这行代码选取了所有行的所有列的第二个颜色通道。在 BGR 颜色空间中,第二个通道对应的是绿色通道,所以这行代码获取了 ROI 中所有的绿色通道值。

在大多数图像处理库中(如 OpenCV),图像默认是以 BGR 顺序存储的,而不是 RGB。这意味着在获取颜色通道时需要使用 [ : , : , 2] 来获取红色通道,[ : , : , 1] 来获取绿色通道,[ : , : , 0] 来获取蓝色通道。

# 激光点RGB值获取
def get_pixel_sum(image, coords):
# 获取图像宽度和高度
height, width = image.shape[:2]
radius = 3
# 确定方圆的左上角和右下角坐标
x, y = coords
x_start = max(0, x - radius)
y_start = max(0, y - radius)
x_end = min(width - 1, x + radius)
y_end = min(height - 1, y + radius)
# 提取方圆区域
roi = image[y_start:y_end, x_start:x_end]
# 计算 R 和 G 通道总值
r_channel = roi[:, :, 2]
g_channel = roi[:, :, 1]
r_sum = int(r_channel.sum())
g_sum = int(g_channel.sum())
return r_sum, g_sum

cv2.circle(frame, laser_coords, 4, (0, 0, 255), -1)

绘制实心圆

cv2.putText()

  1. 参数解释:
    • frame:这是目标图像帧,即要在其上添加文本的图像。
    • "RED":要写入的文本内容,这里是字符串 “RED”。
    • (laser_coords[0] - 10, laser_coords[1] - 10):文本放置的起始位置,由 laser_coords 提供,向左上方偏移了 (10, 10) 的像素值。
    • cv2.FONT_HERSHEY_SIMPLEX:字体类型,这里使用了简单的字体类型。
    • 0.5:字体大小因子,控制文本大小相对于基础字体的比例。
    • (0, 0, 255):文本的颜色,这里是红色,对应 BGR 颜色空间中的 (0, 0, 255)
    • 2:文本的线宽,即文本轮廓的粗细。
  2. 作用说明:
    • 这行代码的主要作用是在图像帧的指定位置绘制红色的文本 “RED”。通常用于在图像或视频中标记特定的对象、位置或状态信息。
  3. 注意事项:
    • 如果 laser_coords 提供的坐标 (x, y) 位于图像的边缘,确保 (x - 10, y - 10) 不会超出图像边界,以避免出现越界错误。
# 是否获取到激光点矩形框的中心坐标,防止读取值为空
if laser_coords is not None:
# 获取激光点的RGB数据
color_vel = get_pixel_sum(image, laser_coords)
# 在红色激光点中心坐标出绘制圆点图像(原图像中)
cv2.circle(frame, laser_coords, 4, (0, 0, 255), -1)
# 打印文本数据“RED”在红色激光点上
cv2.putText(frame ,"RED", (laser_coords[0] - 10, laser_coords[1] - 10), cv2.FONT_HERSHEY_SIMPLEX, 0.5, (0, 0, 255), 2)

2.4差值计算与串口通信#

PS:上位机与下位机通信,建议采用十六进制:帧头+数据类型+数据内容 的单帧数据包格式,这样才能保证下位机能够同步反应上位机发送的特征值,减小通信的复杂度,同时可靠性和速率较高,不建议采用发送字符串类型的通信。

if mode

3.胶带矩形框识别#

3.1 ROI区域的创建#

PS: 使用ROI区域后,又想还原到原图像上对图像进行处理,就需要对坐标进行还原操作,保证图形每个像素点的索引和原图像对应

class ROIPixelProcessor:
def __init__(self):
pass
def process_roi(self, image, roi):
"""
取得 ROI 区域的像素值进行处理,但不改变索引号
:param image: 输入图像
:param roi: ROI 参数 (x, y, width, height)
:return: ROI 区域的像素值
"""
x, y, w, h = roi
# 确保 ROI 在图像范围内
if x < 0:
w += x
x = 0
if y < 0:
h += y
y = 0
if x + w > image.shape[1]:
w = image.shape[1] - x
if y + h > image.shape[0]:
h = image.shape[0] - y
# 提取 ROI 区域的像素值
roi_pixels = image[y:y+h, x:x+w]
return roi_pixels

3.2 图像的预处理#

通过BGR转灰度,自设定阈值进行二值化地方法,将胶带部分转换为白色单值,其他部分全为黑色单值,并且通过滤波、腐蚀、闭运算地方法对图形进行处理,降低噪点干扰,提高图形识别完整性。

if time.time()-start_time <= 5:
# 将图像转换为灰度图
gray = cv2.cvtColor(frame, cv2.COLOR_BGR2GRAY)
#cv2.imshow('gray',gray)
gray = cv2.GaussianBlur(gray, (9, 9), 0) #高斯平滑滤波,(9,9)是卷积核,0 代表函数自动计算标准差
#cv2.imshow('gauss',gray)
# 对灰度图进行阈值处理,将黑色部分变为白色,其他部分变为黑色
_, threshold = cv2.threshold(gray, 120, 255, cv2.THRESH_BINARY_INV)
# 进行形态学闭操作,填充内部空洞
kernel = np.ones((5,5),np.uint8)
closing = cv2.morphologyEx(threshold, cv2.MORPH_CLOSE, kernel, iterations=3)
# 进行形态学腐蚀操作,缩小黑框大小,减少噪点信号干扰
kernel = np.ones((3,3),np.uint8)
erosion = cv2.erode(closing,kernel,iterations = 1)
#dilation = cv2.dilate(threshold, kernel, iterations=1)

3.3 矩形框轮廓的识别#

同样地,使用封闭图形检测算法,检测矩形框轮廓,这里使用 ROI处理,要特别注意像素点索引(坐标)的复原的操作。

即:

# 将轮廓绘制在原始图像上
for contour in contours:
# 将轮廓点的坐标映射回原始图像的坐标系
contour += (x, y) # 将ROI的偏移加回来

近似曲线

注意:这里approx_contour得到的是最终近似矩形的四个角点的坐标!

epsilon = 0.01 * cv2.arcLength(max_contour, True)
approx_contour = cv2.approxPolyDP(max_contour, epsilon, True)
  • cv2.arcLength 用于计算轮廓的周长或弧长。
  • epsilon 是一个近似精度参数,通常是轮廓周长的某个百分比,这里设定为总周长的1%。
  • cv2.approxPolyDP 用来对轮廓进行多边形逼近,通过减少顶点的数量来近似表示轮廓。
  • approx_contour 是近似后的多边形轮廓。

找到左右轮廓的边界点(列表)

leftmost[0]就是左轮廓第一个点

leftmost = min(path, key=lambda x: x[0]) # 左轮廓边界坐标点
rightmost = max(path, key=lambda x: x[0]) # 右轮廓边界坐标点
  • path 是一组坐标点的集合,通常表示为一个列表或数组。
  • min(path, key=lambda x: x[0])max(path, key=lambda x: x[0]) 分别用于找到 path 中横坐标 x 最小和最大的点,即左轮廓和右轮廓的边界点。

参数说明

  • lambda x: x[0] 是一个匿名函数,用于指定以每个点的横坐标 x 作为比较的关键字。因此,min 函数找到具有最小横坐标的点,而 max 函数找到具有最大横坐标的点。

计算轮廓的中心点

center_point_x = int(sum([point[0] for point in path]) / len(path))
center_point_y = int(sum([point[1] for point in path]) / len(path))
center_point = (center_point_x, center_point_y)
  • path 是一组轮廓的坐标点集合,通常表示为一个列表或数组。
  • sum([point[0] for point in path])path 中所有点的横坐标进行求和。
  • sum([point[1] for point in path])path 中所有点的纵坐标进行求和。
  • len(path)path 中点的数量,即轮廓的长度或大小。

参数说明

  • center_point_xcenter_point_y 分别是计算得到的轮廓中心点的横坐标和纵坐标。
  • center_point 是由 center_point_xcenter_point_y 组成的元组,表示轮廓的中心点坐标 (x, y)

作用

  • 这段代码的作用是计算轮廓 path 的几何中心点,通过对所有点的坐标进行平均值计算得出
#cv2.imshow('dilation',erosion)
roi = closing[center_y-120:center_y+120, center_x-120:center_x+120]#创建ROI图像
#cv2.imshow('roi',roi)
x,y,w,h = center_x-120,center_y-120,240,240 #ROI左上角点为220,140,ROI检测框高度为200,宽度为200
#绘制ROI矩形
cv2.rectangle(frame,(x,y),(x+w,y+h),(0,255,0),1)
#cv2.imshow('erosion',erosion)
# 利用轮廓检测函数找到黑色框的轮廓
contours, hierarchy = cv2.findContours(roi, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE)
# 轮廓 层级 轮廓检索模式 轮廓逼近方法
# 将轮廓绘制在原始图像上
for contour in contours:
# 将轮廓点的坐标映射回原始图像的坐标系
contour += (x, y) # 将ROI的偏移加回来
#cv2.drawContours(frame, [contour], -1, (0, 0, 255), 2) 原始轮廓,这里用近似法得到更平滑的外围轮廓
# 如果找到了轮廓
if len(contours) > 0:
# 提取最大的轮廓
max_contour = max(contours, key=cv2.contourArea)
# 近似曲线
epsilon = 0.01 * cv2.arcLength(max_contour, True)
approx_contour = cv2.approxPolyDP(max_contour, epsilon, True)
# 获取路径坐标
path = []
for point in approx_contour:
path.append(tuple(point[0]))
# 找到左右轮廓的边界点
leftmost = min(path, key=lambda x: x[0])# 左轮廓边界坐标点
rightmost = max(path, key=lambda x: x[0])# 右轮廓边界坐标点
# 计算左右轮廓的中间点
middle_x = int((leftmost[0] + rightmost[0]) / 2)# x中心坐标
middle_y = int((leftmost[1] + rightmost[1]) / 2)# y中心坐标
middle_point = (middle_x, middle_y)
# 绘制左右轮廓中心点
#cv2.circle(frame, middle_point, 5, (0, 255, 0), -1)
# 计算轮廓的中心点
center_point_x = int(sum([point[0] for point in path]) / len(path))
center_point_y = int(sum([point[1] for point in path]) / len(path))
center_point = (center_point_x, center_point_y)
cv2.drawContours(frame, [np.array(path)], -1, (125, 255, 125), 1) #近似后的轮廓
# 轮廓 第几个(默认-1:所有) 颜色 线条厚度
# 绘制轮廓中心点
cv2.circle(frame, center_point, 3, (0, 255, 0), -1)
resize = 0.92
resize_path = []
for point in path:
resize_x = int(center_point_x + resize*(point[0]-center_point_x))
resize_y = int(center_point_y + resize*(point[1]-center_point_y))
resize_path.append((resize_x,resize_y))
cv2.drawContours(frame, [np.array(resize_path)], -1, (125, 255, 125), 1) # 内缩后的轮廓
long_path = interpolate_points(resize_path)
for point in long_path:
cv2.circle(frame, point, 4, (0, 0, 255), -1)

3-4 矩形框的路径规划-插值算法#

如果让激光点沿着轮廓一个一个坐标点去走,这样计算量会相当大,不如将矩形轮廓通过近似得到四个角点,再通过插值法,设置相邻点的插值参数,得到12个路径点,这样就既保证了巡线的稳定性,也大大减少了巡线的计算量。同时,通过调用time库来计时,程序启动时定时5s后,这段时间提供给我们确认摄像机状态,移动ROI区域,完成路径规划,实现“动态建图”的效果。

#插值算法
def interpolate_points(points):
interpolated_points = []
for i in range(len(points)):
start_point = points[i]
end_point = points[(i+1) % len(points)]
# 计算两点之间的距离
dx = end_point[0] - start_point[0]
dy = end_point[1] - start_point[1]
distance = 3
# 根据距离进行插值
for j in range(distance):
x = start_point[0] + int(dx * j / distance)
y = start_point[1] + int(dy * j / distance)
interpolated_points.append((x, y))
return interpolated_points

【电赛】01 - 2023E题运动控制与目标追踪(视觉部分)
http://www.turinblog.cn/posts/电赛01---2023e题运动控制与目标追踪视觉部分/
作者
Szturin
发布于
2024-07-16
许可协议
CC BY-NC-SA 4.0