资讯动态

2024电赛E题三子棋Python源码解析:从minimax到串口协议

发布时间:2026/9/16 15:26:25 来源:尧图企业网站定制
简介这份资源为2024年电子设计竞赛E题三子棋游戏的Python完整源码作者凭此获得省赛一等奖适合参加电赛的在校生、自动化/电子信息等专业学生以及想学习机器视觉与博弈算法的开发者。代码中提供了三子棋不败算法、棋子识别、电磁铁控制与灯光控制等模块覆盖从棋盘检测到落子执行的完整流程可直接在竞赛硬件上部署参考。压缩包共27个文件以20个Python脚本为主另含5个编译缓存文件、1个说明文档和1个许可证文件整体仅45KB结构精简易读便于按模块分析或二次开发。目前已有699人学习下载对于想快速掌握电赛E题实现思路的读者是一份兼具实战性与参考价值的入门范例。1. 2024年电赛E题里的那套Python源代码到底在解什么“2024年电赛E题三子棋游戏源代码python”这个搜索串,大概率对应的是赛后流传出来的完整工程下位机用STM32驱动机械臂摆棋子,上位机用OpenMV与Python负责视觉识别、棋局判读和决策。比赛的核心难点不在于“三子棋规则”本身,而在于把一套需要实时闭环的自动化装置做到稳定可复现——这就决定了Python在这里不是去抢MCU的活,而是承接了整套系统里最需要迭代速度的三块棋局逻辑、落子策略、串口通信协议。这篇文章就顺着这套分工,把源代码里最值得复用的部分拆开讲清楚。适合看这篇文章的,是正在备赛或准备复现这道题的同学,也包括想找“上位机MCU”合作范式的人。你会看到一份完整方案的职责划分、每段核心代码的写法,以及最容易让系统在赛场上当场翻车的那些隐性坑。2. 三子棋核心实现棋盘建模、胜负判定与AI策略2.1 棋盘建模用列表就够,别一开始就上类E题比的是装置不是架构,所以棋盘状态用一个3x3的二维列表即可。常见误区是一上来就封装Board类,重写__eq__、实现copy.deepcopy,结果AI部分还没写,光模型层就占了上百行。我一般这样处理# board.py EMPTY, O, X 0, 1, 2 # 用整数代替字符,方便算分和存串口 def new_board(): return [[EMPTY] * 3 for _ in range(3)] def legal_moves(board): return [(r, c) for r in range(3) for c in range(3) if board[r][c] EMPTY] def win_combos(): return [ [(0,0),(0,1),(0,2)], [(1,0),(1,1),(1,2)], [(2,0),(2,1),(2,2)], [(0,0),(1,0),(2,0)], [(0,1),(1,1),(2,1)], [(0,2),(1,2),(2,2)], [(0,0),(1,1),(2,2)], [(0,2),(1,1),(2,0)] ] def check_winner(board, player): return any(all(board[r][c] player for r, c in comb) for comb in win_combos())这里用整数0、1、2表示空、O、X,而不是字符串,目的是后续通过串口发送棋子坐标时可以直接拼字节,省去字符转换。legal_moves()返回所有空位,供AI搜索和随机落子共用。win_combos()把8条获胜线写死,比每次动态生成相邻关系更直白,在嵌入式调试时也容易对照。2.2 胜负判定要覆盖三种终局赢、平、还在下终局判断不能只查赢家。AI搜索时需要区分“对方赢了”“棋盘满了”“还可以继续”三种状态,否则minimax的返回值会混乱。把判定写成一个总入口def game_over(board): for player in (O, X): if check_winner(board, player): return player if not legal_moves(board): return 0 # 平局 return None # 未结束game_over()的返回值设计成整数,和棋盘状态用同一套编码,这样minimax里直接return game_over(board)就能作为叶子节点得分。2.3 minimax必须写α-β剪枝,电赛的算力经不起全量搜索如果赛题不做任何限制,那AI就是必胜——因为三子棋先手必不败,后手只要走对也能逼平。所以比赛评分点通常是“AI获胜的步数”和“装置稳定性”。但有一个现实约束如果下位机主控用的是64MHz级别的MCU,题目往往不允许你在板子上跑重计算,而是希望上位机算完再下发。因此源代码里AI部分做成一个独立函数,方便在电脑上先验证算法正确性,再决定是否挪到下位机。import math def minimax(board, depth, alpha, beta, is_max): winner game_over(board) if winner is not None: if winner X: return 10 - depth if winner O: return depth - 10 return 0 if depth 0: return evaluate(board) moves legal_moves(board) if is_max: best -math.inf for r, c in moves: board[r][c] X score minimax(board, depth - 1, alpha, beta, False) board[r][c] EMPTY best max(best, score) alpha max(alpha, score) if beta alpha: break return best else: best math.inf for r, c in moves: board[r][c] O score minimax(board, depth - 1, alpha, beta, True) board[r][c] EMPTY best min(best, score) beta min(beta, score) if beta alpha: break return bestdepth的设定很关键。三子棋满盘只有9个格子,minimax不加剪枝最坏要展开9!个节点,但加α-β后搜索整棵树也就毫秒级。实战里常见做法是控制在depth6、开局拉满,在树节点太少时反而会“一眼看穿”。evaluate()在深度受限时评估局势,简单版本可以统计中心、角位、边的权重,但在9格棋盘里,直接搜到底比启发式更准。2.4 落子入口先手后手与难度挡位最终对外的接口是给视觉模块调用的,所以这里直接写成“给出棋盘,返回落子坐标”def choose_move(board, ai_playerX, depth6): moves legal_moves(board) if not moves: return None if len(moves) 9: return (1, 1) # 先手占中 best_move moves[0] if ai_player X: best_val -math.inf for r, c in moves: board[r][c] X val minimax(board, depth - 1, -math.inf, math.inf, False) board[r][c] EMPTY if val best_val: best_val val best_move (r, c) else: best_val math.inf for r, c in moves: board[r][c] O val minimax(board, depth - 1, -math.inf, math.inf, True) board[r][c] EMPTY if val best_val: best_val val best_move (r, c) return best_move注意到先手首步直接占中心,没有进搜索树。这是三子棋的先验知识,能省掉不少开局节点。难度挡位可以通过depth叠加随机选择实现把best_move的候选集改成“得分不低于最优解2分的几个位置里随机选”。3. 上下位机通信源码里的串口协议与帧校验3.1 为什么上位机不下发坐标,而要下发行数很多第一次接触电赛E题的人会犯一个错误直接在Python里算出机械臂的关节角度,通过串口发给STM32。这在调试时或许能跑,但一到赛场上就会发现,机械臂抖动、棋盘位置偏移、棋子厚度公差都会让固定角度失效。正确做法是协议只传“第几行第几列”,机械臂末端位置由下位机自己维护。棋盘坐标系在固体层标定一次,而Python端只负责“判断哪里落子”。这样视觉标定和机械臂控制彻底解耦,代码也能分成两队并行开发。3.2 用固定帧长替代复杂的分包拆包比赛场景是单字节串口,干扰不小,所以帧格式要尽量简单。我见过不少队伍用JSON传坐标,结果下位机解析json非常痛苦,还容易因为边界字符错位导致死循环。正确且省事的方案是定长帧字节偏移名称值域说明0帧头0xAA固定1命令字0x01落子 / 0x02复位 / 0x03认输2行号0~2待落子位置3列号0~24校验和低8位前4字节求和# uart_protocol.py import serial import time def build_frame(cmd, row, col): data bytearray([0xAA, cmd, row, col]) checksum sum(data) 0xFF return data bytes([checksum]) if __name__ __main__: ser serial.Serial(/dev/ttyUSB0, 115200, timeout0.1) frame build_frame(0x01, 1, 2) ser.write(frame) time.sleep(0.05) if ser.in_waiting 5: resp ser.read(5) print(ack:, resp[1], resp[2], resp[3], resp[4])这个帧协议有五字节固定长度,下位机只需要维护一个5字节的循环缓冲区,读到0xAA后开始计数,收到第5字节时做校验。checksum只取低8位,是为了把计算量压在单字节运算上,下位机用C写时不会溢出。3.3 下位机粘包与丢包的处理串口没有天然帧边界,如果下位机在上电初始时收到半个帧,那么后续所有数据都会错位。所以协议里帧头0xAA必须紧跟校验和,而且下位机在发现校验失败后要主动抛弃当前字节并重新搜索帧头。这个“丢弃字节直至找到帧头”的逻辑,先用Python模拟一下更稳def parse_frame(stream): buf bytearray() for b in stream: buf.append(b) if len(buf) 5: s sum(buf[:4]) 0xFF if s buf[4] and buf[0] 0xAA: yield buf[1], buf[2], buf[3] buf.clear() # 无论对错都复位Python里的yield把串口解析器变成生成器,方便在主循环里循环读取。注意这里不需要维护一个大的while判断字节索引,因为帧长固定,解析状态机天然简单。放在代码里的真正价值是下位机C代码可以用同样的状态图,先在PC端验证完逻辑再移植。4. 视觉识别模块用OpenMV定位棋子与棋盘4.1 摄像头选型和颜色阈值陷阱手头的工程如果用的是OpenMV,那么它的find_blobs()是识别棋子的主要手段。但一个常见坑是,棋子颜色与棋盘底色接近,比如木板色偏红、黑棋子带光泽,导致LAB阈值调节困难。我一般把阈值写在配置段,方便现场重新校准import sensor, image, time sensor.reset() sensor.set_pixformat(sensor.RGB565) sensor.set_framesize(sensor.QVGA) sensor.skip_frames(100) sensor.set_auto_whitebal(False) # 关闭白平衡,保证颜色稳定 red_threshold (30, 90, 30, 80, -10, 60) # (L_lo, L_hi, A_lo, A_hi, B_lo, B_hi)关白平衡是必须的,否则现场灯光一变,阈值立刻失效。有些队伍为了省事不关,结果上午好好的,下午换了灯光直接全场误识别。set_auto_whitebal(False)之后,还需要在标定时固定曝光sensor.set_auto_exposure(False),这一点经常被忽略。4.2 识别并排序为3x3网格拿到颜色团块后,要映射到棋盘。不能用像素绝对坐标,而是用相对位置def locate_blobs(img, threshold, expected9): blobs img.find_blobs([threshold], pixels_threshold60, area_threshold60, mergeTrue) cells [] for b in blobs: if b.area() 200: continue cells.append((b.cx(), b.cy())) if len(cells) expected: return None # 按x坐标三等分、y坐标三等分,得到0~2 xs sorted(set(c[0] // (img.width() // 3) for c in cells)) ys sorted(set(c[1] // (img.height() // 3) for c in cells)) return [(x, y) for y in ys for x in xs]这里b.cx()和b.cy()是团块中心。mergeTrue可以把同一个棋子被拆成两半的情况合并,降低识别数超过9的风险。实际部署时,pixels_threshold需要根据摄像头离棋盘的距离调整,贴得太近时棋子会占满半个屏幕,此时area_threshold反而要加大。4.3 从图片坐标到落子坐标的标定过程如果你发现识别的行列总是错位,问题通常不是算法,而是棋盘没摆正。源码里常见的补救办法是四点透视矫正,但比赛现场往往不允许额外标定板。这里给一个省事的折中方案先让机械臂依次走一遍 (0,0) 到 (2,2) 的空棋盘,用模板匹配找九个点位,然后存成一个calibration.json:{ cell_size: 42, origin: [56, 78], rotate_deg: 0.5 }有了origin和cell_size,视觉模块在识别到棋子像素坐标后,按(col * cell_size origin_x, row * cell_size origin_y)换算。这个方法在赛场上比完整的单应性变换更好调,因为现场没有时间让你反复去调get_perspective_transform()的四个角点。5. 主控状态机把逻辑、串口和视觉封装成可联调的流程5.1 为什么需要状态机而不是顺序执行很多初版源码是用sleep()串联模块的先拍照、再识别、再串口发送、再等机械臂动。这能跑,但一旦某一步超时,整个流程直接卡死。比赛测试往往有严格时间限制,一次卡死可能就白搭。用有限状态机可以做到任何状态的超时都能回到安全态重新开始。from enum import Enum import time class State(Enum): IDLE 0 CAMERA 1 DECIDE 2 SEND 3 WAIT_ACK 4 def run(state, board, cam, uart): if state State.IDLE: board new_board() return State.CAMERA if state State.CAMERA: img cam.snapshot() user_move locate_blobs(img, red_threshold) if user_move is not None: board[user_move[0]][user_move[1]] O return State.DECIDE return State.CAMERA if state State.DECIDE: move choose_move(board, ai_playerX, depth6) if move: board[move[0]][move[1]] X return State.SEND return State.IDLE if state State.WAIT_ACK: if uart.in_waiting 5: frame uart.read(5) if frame[1] 0x01: # ACK return State.CAMERA return State.WAIT_ACKstate的切换是显式的,每一步之间没有隐藏的sleep,这样可测试性最强——你可以手动把DECIDE的返回值设为None,然后观察状态机是回到IDLE还是停滞。比赛联调时也便于对着状态表逐项验证。5.2 把超时和重试加进状态机状态机必须挂一个“回家”的定时器,否则机械臂走到一半掉线,状态就永远卡在WAIT_ACK。常见做法是在WAIT_ACK记录last_time,超过500毫秒就重新SEND一次,最多重试3次,超限进入人工复位状态。retry 0 max_retry 3 last_send_time time.ticks_ms() while True: if state State.SEND: uart.write(build_frame(0x01, move[0], move[1])) last_send_time time.ticks_ms() state State.WAIT_ACK if state State.WAIT_ACK: if uart.any(): frame uart.read(5) if frame[4] (sum(frame[:4]) 0xFF): state State.CAMERA retry 0 else: uart.write(build_frame(0x04, 0, 0)) # 请求重发 elif time.ticks_diff(time.ticks_ms(), last_send_time) 500: retry 1 if retry max_retry: state State.IDLE retry 0 else: uart.write(build_frame(0x01, move[0], move[1]))最后这个循环把整个源码串了起来。注意校验失败是回CAMERA还是回SEND,取决于计分规则里是否允许重复动作;如果不允许重复,则应当回到CAMERA重新识别当前局面。6. 收尾调试三处最容易让整套装置当场报废的地方6.1 串口波特率与字符缓冲区错位很多复现者直接把代码烧进板子,发现机械臂乱动,第一反应是改逻辑。其实最需要检查的是下位机串口初始化的IdleLine中断。如果下位机在收帧时没有清空接收缓冲,那么一旦上位机发数据时下位机正在忙别的事,旧数据会被当成新帧产生误动作。我一般在串口初始化后自动发一帧0xAA 0x05 0x00 0x00 0xAF,确认下位机应答再进入主循环。def wait_for_boot(uart, timeout_ms1000): uart.write(build_frame(0x05, 0, 0)) start time.ticks_ms() while time.ticks_diff(time.ticks_ms(), start) timeout_ms: if uart.any() and uart.read(5) bytes([0xAA, 0x06, 0x00, 0x00, 0xB0]): return True return False这一小步能避免“上电时序”引发的伪丢包问题。如果返回值始终为假,优先用串口调试助手检查下位机是否没进入接收中断,而不是急着DEBUG算法。6.2 棋子边界误差导致识别偏移当棋盘中心点边缘有一圈反光时,find_blobs的cx会偏向高光侧,进而让格子映射出错。解决办法不是做复杂的边缘检测,而是在找到团块后把中心点做一个偏移,常见补偿公式是cx_fixed cx (threshold_area / blob_area - 1) * offset。但更省事的做法是让棋盘底色与棋子颜色反差拉大,把棋子喷成哑光。6.3 调AI参数前,先回归一次规则赛前最容易出现的错误是只调AI,不回归三子棋规则本身。比如你改了depth从6到8,结果发现先手首步不再占中,于是开始怀疑数据结构有问题。这时先跑一个最小验证脚本,把已知棋谱喂进去,确认choose_move的行为没有回归,再回到装置联调。把这个验证脚本写进仓库根目录,命名为selftest.py# selftest.py b [[O, O, X], [X, EMPTY, X], [EMPTY, O, EMPTY]] mv choose_move(b, ai_playerX, depth6) assert mv (2, 0), fexpected block, got {mv} print(selftest passed)如果这条断言挂了,说明AI逻辑有改动没跟上,先去修算法,不要上真机调机械臂。把状态机里串口超时回退和机械臂到位检测提前到这个最小用例里,整个联调周期能压缩很多。本文还有配套的精品资源点击获取

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价