开源分享 | 2024年电赛E题三子棋视觉方案 旭日X3派主控(OpenCV+PyQt5)

四年经典老电赛选手,暑假在家里摸鱼,没多余材料,出于锻炼技术和兴趣使然,在家参与了今年电赛。主控使用旭日X3派,远程连接使用MobaXterm,有OpenCV和PyQt5库就能运行,理论上树莓派等Linux板子搭建好环境都能用。第一次玩OpenCV项目,如有问题请见谅。代码工程及思路PPT同时也在Github平台发布,链接:https://github.com/ryhxf/2024-NUEDC-E-
希望点个Star!

详解教程已发布在B站,最终演示效果在视频开头,链接:https://www.bilibili.com/video/BV167YBeDEdY/?vd_source=9b34f7929a65a06b390a29951e1e499f

注意:运行前环境库(OpenCV+PyQt5)需配好,代码第2行和第369行需根据个人情况修改和注释。-
代码(362-367行的使用的图片路径需要自行修改,可自行改成想要的图)

1.图像预处理部分(739行)参数可调,个人觉得可用可不用

blurred = cv2.GaussianBlur(frame, (5, 5), 0) # 高斯模糊
kernel = np.ones((5, 5), np.uint8) # 5*5的卷积核
opened = cv2.morphologyEx(blurred, cv2.MORPH_OPEN, kernel) # 开运算
# cv2.imshow("opened", opened) #测试处理后图片

2.转换成灰度图部分(748行)

# 转换为灰度图像
gray = cv2.cvtColor(opened, cv2.COLOR_BGR2GRAY)

3.裁剪图像(并画出切割区域)(773行)左1/8和右1/8块不要,同时加上偏移量

# 取图像的中间部分
height, width = gray.shape
left = width // 8
right = width * 7 // 8
gray = gray[:, left:right]
# 在原图上画出切割的区域
cv2.rectangle(frame, (left, 0), (right, height), (255, 0, 0), 2)



# 加上偏移量
x += left

4.边缘检测和查找轮廓(784行)低阈值50和高阈值150可修改

# 使用Canny边缘检测
edges = cv2.Canny(gray, 50, 150, apertureSize=3)
# cv2.imshow('edges', edges) # 测试边缘检测
# 查找轮廓
contours, _ = cv2.findContours(edges, cv2.RETR_TREE, cv2.CHAIN_APPROX_SIMPLE)

5.寻找面积最大的轮廓且面积大于一定值,得到大矩形(无旋转)

# 寻找面积最大的轮廓
max_area = 0
largest_contour = None
for contour in contours:
    area = cv2.contourArea(contour)
    if area > max_area:
        max_area = area
        largest_contour = contour

6.使用旋转矩形,cv2.minAreaRect()获取矩形的中心坐标、宽高和旋转角度等(816行)

# 获取旋转矩形的中心点坐标、宽高和旋转角度
rect = cv2.minAreaRect(largest_contour)

7.cv2.boxPoints()函数获取4个顶点,然后计算得到旋转矩形的各种信息并绘制出来(817行)

# 获取旋转矩形的四个角点
box = cv2.boxPoints(rect)

# 将角点坐标转换为整数
box = np.intp(box)
# 给每个角点的x坐标加上偏移量
box[:, 0] += left

# 计算四个角点的中心点
center_points = np.mean(box, axis=0)

小方格排序问题解决(3个小步骤)-
(1)在一开始算大矩形角点就进行排序。(代码828行)

# 对四个顶点进行排序,从左到右,从上到下
sorted_box = sorted(box, key=lambda p: (p[1], p[0]))

(2)角度小于0时,以排为主的顺序排序。角度大于0时,以列为主的顺序排序(代码889行)

if self.angle < 0:
    # 计算每个小正方形的中心点坐标和编号
    for row, col in square_order:
        # 计算小正方形的中心点相对于大矩形中心点的坐标
        relative_center = np.array([(2 * col - 2) * self.width / 6, (2 * row - 2) * self.height / 6])

        # 使用旋转矩阵来旋转小正方形的中心点
        rotated_center = np.dot(rotation_matrix, relative_center)

        # 计算小正方形的中心点的绝对坐标
        absolute_center = self.center_big_square + rotated_center

        # 将中心坐标和编号添加到列表中
        self.squares_center.append((absolute_center, row * 3 + col + 1))
else:
    # 以列为主的顺序计算每个小正方形的中心点坐标和编号
    for col, row in square_order:
        # 计算小正方形的中心点相对于大矩形中心点的坐标
        relative_center = np.array([(2 * col - 2) * self.width / 6, (2 * row - 2) * self.height / 6])
        # 使用旋转矩阵来旋转小正方形的中心点
        rotated_center = np.dot(rotation_matrix, relative_center)
        # 计算小正方形的中心点的绝对坐标
        absolute_center = self.center_big_square + rotated_center
        # 将中心坐标和编号添加到列表中
        self.squares_center.append((absolute_center, row * 3 + col + 1))
        # 画出每个小正方形的内接圆
        cv2.circle(frame, (int(absolute_center[0]), int(absolute_center[1])), int(self.neijie_radius), (255, 0, 0), 2)

(3)角度大于0时,错误的方格信息修正(代码916行)

if(self.jiaodu > 0):
    # 修正不同的格子的所有信息。1号和3号互换,2号和6号互换,5号和7号互换,0,4,8不变
    self.squares_center[1], self.squares_center[3] = self.squares_center[3], self.squares_center[1]
    self.squares_center[2], self.squares_center[6] = self.squares_center[6], self.squares_center[2]
    self.squares_center[5], self.squares_center[7] = self.squares_center[7], self.squares_center[5]

8.使用旋转矩阵来旋转每个小正方形的中心点和绝对坐标(860行)

# 创建旋转矩阵
rotation_matrix = np.array([[np.cos(self.angle), -np.sin(self.angle)], [np.sin(self.angle), np.cos(self.angle)]])

# 初始化一个列表用于存储小正方形的中心点坐标和编号
self.squares_center = []
# 初始化一个列表用于存储小正方形的四个顶点坐标
self.square_points = []

# 计算大矩形的中心点
self.center_big_square = np.array([(self.top_left[0] + self.bottom_right[0]) / 2, (self.top_left[1] + self.bottom_right[1]) / 2])

# 确保 self.royal_rect_points 是一个 NumPy 数组

self.royal_rect_points = np.array([self.top_left, self.top_right, self.bottom_right, self.bottom_left], dtype=np.int32)
self.royal_rect_points = self.royal_rect_points.reshape((-1, 1, 2))

# 计算旋转矩阵的宽度和高度
self.width = max(self.distance(self.top_left, self.top_right), self.distance(self.bottom_left, self.bottom_right))
self.height = max(self.distance(self.top_left, self.bottom_left), self.distance(self.top_right, self.bottom_right))

#绘制出当前矩形
cv2.drawContours(frame, [box], 0, (0, 255, 0), 2)
# 定义顺序,最左上角开始编号
square_order = [
    (0, 0), (0, 1), (0, 2),
    (1, 0), (1, 1), (1, 2),
    (2, 0), (2, 1), (2, 2)
]

9.使用Hough圆变换来检测圆形(1033行)-
调节param2值,值越小,越容易识别

original_circles = cv2.HoughCircles(edges, cv2.HOUGH_GRADIENT, dp=1, minDist=20, param1=50, param2=20, minRadius=20, maxRadius=50)

加入限定面积大小的条件,再滤一些圆

for i in circles:
    center = (i[0] + left, i[1])  # 仅对x坐标添加偏移量
    radius = i[2]

    # 检查圆的面积是否在指定范围内
    if np.pi * (radius ** 2) < 5000:  # 可根据需要调整此值
    # 后面对圆做处理

10.根据获得的圆形,进行颜色判定,决定黑白棋子(1089行)

# 获取棋子中心点的颜色
color = frame[center[1], center[0]]
# 打印棋子的颜色值
#print(f"棋子中心 ({center[0]}, {center[1]}) 的颜色值: BGR({color[0]}, {color[1]}, {color[2]})")

# 掩模清空
self.white_mask = None

# 生成白色和黑色掩模
self.white_mask = cv2.inRange(frame, self.white_lower, self.white_upper)
self.black_mask = cv2.inRange(frame, self.black_lower, self.black_upper)




# 检查掩模中该位置的像素值
if self.white_mask[center[1], center[0]] > 0:
    white_count += 1

    circle_color = (0, 0, 0)  # 黑色
    center_color = (0, 0, 0)  # 黑色
elif self.black_mask[center[1], center[0]] > 0:
    black_count += 1

    circle_color = (255, 255, 255)  # 白色
    center_color = (255, 255, 255)  # 白色
else:

        continue  # 如果不匹配任何颜色范围,则跳过

滤除靠近矩阵内的杂圆,通过cv2.pointPolygonTest函数判断圆是否在大矩阵外面。(1120行)

pt = (float(center[0]), float(center[1]))  # 使用元组表示点的坐标
circle_in_square = True # 重置参数,用于后面画圆判别条件
yuan_point_position = cv2.pointPolygonTest(self.royal_rect_points, pt, False)

# 打印结果
if yuan_point_position > 0:
    # print(f"圆 {i} 在矩形内部")
    count_yuan_in += 1

elif yuan_point_position == 0:
    # print(f"圆 {i} 在矩形边界上")
    p = 0
    #不做处理
else:
    # print(f"圆 {i} 在矩形外部")
    count_yuan_out += 1
    if self.white_mask[center[1], center[0]] > 0:
        count_yuan_out_White += 1
    elif self.black_mask[center[1], center[0]] > 0:
        count_yuan_out_Black += 1
    circle_in_square = False

11.矩阵外的黑白棋子识别效果很好,但是矩阵内圆识别非常的乱-
1.同一个矩形可能同时判别到多个圆-
2.多个圆有时候颜色是不同的-
3.大矩形内的圆识别效果非常差,处理很多帧,往往只有一帧识别到(通过打印九宫格状态观察)-
视频中进行失败方案列举,此处不展示。

最终解决方案-
根据各方格内接圆的平均阈值判别黑白无棋三种状态。-
面对现场调试方案,平均阈值取值由一开始的界面按钮调试来决定,先摆放若干黑白棋子,点击9个按钮至对应状态,点击未存按钮至保存即可,self.test_jiugongge数组为一开始调试的九宫格子状态(0,1,2分别代表无,黑,白),self.jiugongge数组存储为判别得到的最终九宫格子状态

计算内接圆颜色平均值

# 计算每个内接圆的颜色平均值
mask = np.zeros((height, width), np.uint8)
center_int = tuple(center.astype(int))
radius_int = int(self.neijie_radius)
cv2.circle(mask, center_int, radius_int, 1, thickness=-1) #画出内接圆

average_color = cv2.mean(frame, mask=mask)[:3]
self.average_colors.append(average_color)

根据预设格子信息得到对应的颜色判别阈值

# 判断每个内接圆是否有棋子,以及棋子的颜色
# 通过self.test_jiugongge[]数组,调试无,黑,白的阈值部分
# # 筛选self.test_jiugongge[]数值中为1的格子
# selected_squares = [center for center, value in zip(self.squares_center, self.test_jiugongge) if value == 1]

# 得到这些格子的average_color[1]和average_color[2]数值
black_selected_colors = [(average_color[1], average_color[2]) for average_color, value in zip(self.average_colors, self.test_jiugongge) if value == 1]
if(self.save_yuzhi == 0):
    # 计算 average_color[1] 和 average_color[2] 的总和
    black_sum_1 = sum(black_color[0] for black_color in black_selected_colors)
    black_sum_2 = sum(black_color[1] for black_color in black_selected_colors)

    # 计算 average_color[1] 和 average_color[2] 的平均值
    black_average_value_1 = black_sum_1 / len(black_selected_colors) if black_selected_colors else 0
    black_average_value_2 = black_sum_2 / len(black_selected_colors) if black_selected_colors else 0

    # 计算 average_color[1] 和 average_color[2] 的最大值
    self.black_max_value_1 = max(black_color[0] for black_color in black_selected_colors) if black_selected_colors else 0
    self.black_max_value_2 = max(black_color[1] for black_color in black_selected_colors) if black_selected_colors else 0

根据阈值判定棋子颜色

for p, average_color in enumerate(self.average_colors):
    # 白棋赋值
    if average_color[0] > self.white_min_value_1 - 20 and average_color[1] > self.white_min_value_2 - 20:
        self.jiugongge[p] = 2
    # 黑棋赋值
    elif average_color[1] < self.black_max_value_1 + 20 and average_color[2] < self.black_max_value_2 + 20:
        self.jiugongge[p] = 1
    else:
        # if(self.jiaodu < 0):
        self.jiugongge[p] = 0

格子预设调试界面编写在界面初始化那一块,内容比较繁杂,这里不专门放了,代码64行开始。

棋子博弈算法部分(1398行)

def check_win(self, jiugongge):
    win_conditions = [
        (0, 1, 2), (3, 4, 5), (6, 7, 8),  # 行
        (0, 3, 6), (1, 4, 7), (2, 5, 8),  # 列
        (0, 4, 8), (2, 4, 6)  # 对角线
    ]
    for a, b, c in win_conditions:
        if jiugongge[a] == jiugongge[b] == jiugongge[c] != 0:
            return jiugongge[a]
    if 0 not in jiugongge:
        return 0
    return -1

def evaluate(self, jiugongge):
    winner = self.check_win(jiugongge)
    if winner == self.zhixing:
        return 10
    elif winner == 3 - self.zhixing:
        return -10

    score = 0
    for (a, b, c) in [(0, 1, 2), (3, 4, 5), (6, 7, 8), (0, 3, 6), (1, 4, 7), (2, 5, 8), (0, 4, 8), (2, 4, 6)]:
        line = [jiugongge[a], jiugongge[b], jiugongge[c]]
        if line.count(self.zhixing) == 2 and line.count(0) == 1:
            score += 5
        if line.count(3 - self.zhixing) == 2 and line.count(0) == 1:
            score -= 4

    return score

def minimax(self, jiugongge, depth, alpha, beta, is_maximizing):
    score = self.evaluate(jiugongge)
    if score == 10 or score == -10:
        return score
    if 0 not in jiugongge:
        return 0

    if is_maximizing:
        best = -float('inf')
        for i in range(9):
            if jiugongge[i] == 0:
                jiugongge[i] = self.zhixing
                best = max(best, self.minimax(jiugongge, depth + 1, alpha, beta, False))
                jiugongge[i] = 0
                alpha = max(alpha, best)
                if beta <= alpha:
                    break
        return best
    else:
        best = float('inf')
        for i in range(9):
            if jiugongge[i] == 0:
                jiugongge[i] = 3 - self.zhixing
                best = min(best, self.minimax(jiugongge, depth + 1, alpha, beta, True))
                jiugongge[i] = 0
                beta = min(beta, best)
                if beta <= alpha:
                    break
        return best

def find_best_move(self, jiugongge):
    best_val = -float('inf')
    self.move = -1

    for i in range(9):
        if jiugongge[i] == 0:
            jiugongge[i] = self.zhixing
            move_val = self.minimax(jiugongge, 0, -float('inf'), float('inf'), False)
            jiugongge[i] = 0

            if move_val > best_val or (move_val == best_val and i == 4):
                self.move = i
                best_val = move_val

    return self.move

其他过程以及UI界面设计不进行详细说明,自行查阅吧。此代码工程只是本人的新手学习记录过程,并不是很专业的项目,有错勿喷。开源代码已经上传到附件中,可自行下载阅览,若有细节疑问,可看讲解视频或评论区留言。-
实际效果,视频中更详细。-

11号.jpg-
jiuge.jpg-
sanqi.py

强??