1. 项目缘起:为什么选择OpenMV做三子棋AI?
大家好,我是老张,一个在嵌入式视觉和AI领域摸爬滚打了十多年的工程师。这些年我玩过不少开发板,从早期的树莓派到各种国产AIoT模组,但说到做快速原型验证,尤其是需要“眼睛”和“大脑”结合的小项目,我第一个想到的还是OpenMV。它就像是一个集成了摄像头的微型Python计算机,开箱即用,省去了大量驱动和底层配置的麻烦。
这次咱们要聊的,就是用OpenMV从头到尾搭建一个能“看见”棋盘、能“思考”策略、还能“动手”下棋的完整三子棋AI系统。听起来是不是挺酷?这可不是一个简单的图像识别demo,而是一个涵盖了机器视觉、AI决策算法、嵌入式系统控制、串口通信的全栈实战项目。你可能会问,三子棋规则这么简单,用OpenMV是不是有点“杀鸡用牛刀”?其实不然。这个项目麻雀虽小,五脏俱全,它完美地串联起了从物理世界感知到数字世界决策,再回到物理世界执行的全过程。你在这个过程中踩过的每一个坑,比如光照变化导致识别不稳、算法决策太慢、串口通信丢数据,都是未来做更复杂项目(比如自动驾驶小车、智能分拣机械臂)的宝贵经验。
我选择OpenMV,主要是看中它的三点:第一是开发效率极高,用Python写图像处理算法,比在单片机上用C折腾OpenCV快太多了;第二是生态友好,星瞳科技提供了非常完善的中文文档和社区支持,新手也能快速上路;第三是硬件接口丰富,自带UART、I2C、SPI,方便连接机械臂、舵机等执行机构。这个项目,咱们的目标不仅仅是让AI能下棋,更是要打造一个稳定、可交互、能应对真实环境挑战的自动化系统。我会把我实际开发中遇到的坑和解决方案都掰开揉碎了讲给你听,保证你跟着做一遍,就能掌握这套方法,应用到自己的创意项目里去。
2. 视觉系统的基石:稳定可靠的棋盘与棋子识别
视觉是整个系统的“眼睛”,眼睛要是看错了,后面的大脑和手全得抓瞎。所以,咱们的第一步,也是最重要的一步,就是让OpenMV在各种环境下都能稳稳地“看”清棋盘和棋子。
2.1 棋盘识别:一次校准,终身受用
原始文章里提到了一个非常聪明的思路:只在第一帧识别棋盘边框,之后就一直用这个边框坐标。这个思路我强烈赞同,并且在实际项目中验证了它的有效性。为什么这么做?因为反复识别矩形框(find_rects)是个计算量不小的操作,而且环境光线、摄像头角度稍有变化,就可能识别不到或者识别错,导致后续的格子划分全部乱套。
我的具体实现和优化是这样的:开机后,我会在镜头前放好棋盘,然后程序进入一个短暂的“校准模式”。在这个模式下,它会连续采集多帧(比如10帧),对每一帧识别到的最大矩形进行面积和长宽比筛选(确保是棋盘而不是别的什么矩形),然后把所有合格矩形的角点坐标求个平均值。这样做的好处是,能平滑掉单帧识别可能出现的微小抖动,得到一个更稳定、更准确的初始棋盘坐标。代码上,我定义了一个calibrate_board()函数来做这个事。
def calibrate_board(img, samples=10):
"""校准棋盘,采集多帧样本取平均角点"""
corners_list = []
for i in range(samples):
rects = img.find_rects(threshold=15000) # 阈值可以调高,更严格
if rects:
# 找面积最大的矩形
max_rect = max(rects, key=lambda r: r.w() * r.h())
# 增加尺寸和长宽比约束,更精准
if 80 < max_rect.w() < 120 and 80 < max_rect.h() < 120 and 0.8 < max_rect.w()/max_rect.h() < 1.2:
corners_list.append(max_rect.corners())
time.sleep_ms(50) # 稍作延时,采集不同时刻的图像
if corners_list:
# 将列表转换为numpy数组便于计算均值 (OpenMV IDE内置了micropython的ulab,类似numpy)
import ulab as np
corners_array = np.array(corners_list)
avg_corners = np.mean(corners_array, axis=0)
return avg_corners.tolist()
return None
拿到这四个角点(corners)后,我就把它们存入全局变量。在后续的主循环里,再也不调用find_rects了,直接基于这个corners去划分九宫格。这就好比你在桌子上用胶带贴出了一个固定的棋盘区域,以后摄像头只管看这个区域里面的事,外面天塌了都跟它没关系,稳定性大大提升。
2.2 棋子检测:阈值调参与形态过滤的双重保险
棋盘固定了,接下来就是识别格子里的棋子是黑是白还是空。这里有两个关键:颜色阈值和形状筛选。
颜色阈值是老大难问题。原始文章给出的black_threshold和white_threshold是一个很好的起点,但你直接拿去用,很可能在你家的灯光下就失灵了。我的经验是,一定要用OpenMV IDE自带的“阈值编辑器”工具(Tools -> Threshold Editor)来现场调试。把棋盘和棋子放在你项目实际要用的光照环境下,用这个工具里的滑块调整LAB(或RGB)各个通道的范围,直到在预览图里,棋子区域被高亮选中,而棋盘背景是黑色。记住,阈值不是一成不变的,白天晚上、开灯关灯都可能需要微调。一个更鲁棒的办法是,采用动态阈值或多阈值融合。比如,可以同时检测“深色”和“浅色”两种阈值,或者根据棋盘格子的平均灰度值来微调黑白棋子的阈值范围。
形状筛选是避免误判的关键。find_blobs找到的是一堆色块,可能包括反光点、棋盘格子线的交叉点、甚至摄像头上的灰尘。所以我们必须通过blob的属性来过滤。对于棋子,我们预期它是一个近似圆形的、有一定面积的色块。
blob.pixels():像素数量,可以过滤掉太小的噪点。blob.area():面积,确保棋子大小合理。blob.roundness():圆度,接近1表示越圆。这是区分棋子和不规则反光斑点的利器。通常黑棋(实心圆)的圆度可以设高一点(如>0.8),白棋(可能因为反光边缘不那么完美)可以设低一点(如>0.4)。
# 在划分好的每个格子ROI内进行检测
blobs_black = img.find_blobs([black_threshold], roi=cell_roi, merge=True, margin=10)
blobs_white = img.find_blobs([white_threshold], roi=cell_roi, merge=True, margin=10)
valid_black = []
for b in blobs_black:
# 综合面积、像素和圆度进行筛选
if b.pixels() > 25 and b.area() > 60 and b.roundness() > 0.75:
valid_black.append(b)
valid_white = []
for b in blobs_white:
if b.pixels() > 20 and b.area() > 40 and b.roundness() > 0.35:
valid_white.append(b)
这里有个我踩过的坑:merge参数和margin参数。merge=True会把相邻的、颜色相近的色块合并,这对于一个完整的棋子来说是好事。但margin(边界扩展)设置不当,可能会把两个靠得近的棋子错误地合并。我的建议是,在棋子摆放不重叠的前提下,可以设置一个较小的margin(比如5-10),或者干脆设为0,然后主要依靠后续的位置判断(一个格子只认一个棋子中心点)来去重。
2.3 状态跟踪与坐标映射:为AI构建数字棋盘
识别出棋子后,我们需要把视觉信息转化为AI能理解的数字信息——也就是那个3x3的二维数组board。这里涉及一个坐标映射的过程。我们的摄像头看到的是扭曲的透视图像,而AI思考的棋盘是逻辑上的3x3网格。
原始文章用插值法计算每个小格子的角点,这个方法非常经典。我在此基础上,增加了一步:计算每个格子的中心点坐标。这个中心点坐标有两个重要作用:第一,用于绘制格子编号,方便调试和观察;第二,也是更关键的,当AI决定下棋位置后,需要把这个逻辑位置(如第5格)转换成图像上的物理坐标(x, y),再通过坐标变换(如果用了机械臂,可能还需要手眼标定)告诉执行机构“把棋子放在这里”。
# 假设corners是之前校准保存的四个角点 [top-left, top-right, bottom-right, bottom-left]
def get_grid_center(corners, row, col):
"""根据棋盘角点和行列号,计算对应格子的中心图像坐标"""
# 双线性插值
row_factor = row / 3.0
col_factor = col / 3.0
top = (corners[0][0] * (1-col_factor) + corners[1][0] * col_factor,
corners[0][1] * (1-col_factor) + corners[1][1] * col_factor)
bottom = (corners[3][0] * (1-col_factor) + corners[2][0] * col_factor,
corners[3][1] * (1-col_factor) + corners[2][1] * col_factor)
center_x = top[0] * (1-row_factor) + bottom[0] * row_factor
center_y = top[1] * (1-row_factor) + bottom[1] * row_factor
return int(center_x), int(center_y)
每次循环,我们遍历9个格子,根据识别结果更新board数组:0为空,1为黑子(玩家),2为白子(AI)。同时,我们可以把黑子和白子的逻辑位置(行列)分别存储到black_positions和white_positions列表中,这个列表后面会直接用于胜负判断,比遍历二维数组更高效。
3. AI大脑:不止于随机,实现有策略的下棋逻辑
视觉部分把现实世界数字化了,接下来就是AI大脑的表演时间。原始文章里的AI逻辑非常简单:遍历棋盘,找到第一个空位就下下去。这确实是个能跑的AI,但毫无策略可言,相当于一个不会思考的婴儿。我们要做的,是给它注入一点“智慧”。
3.1 胜负判断算法:高效检查连线
在思考AI怎么下之前,得先教会它判断输赢。原始文章提供的check_continuous_line函数思路是对的,但实现上可以更简洁高效。三子棋的棋盘很小,我们完全可以用更直接的方式。我的做法是,利用之前准备好的black_positions和white_positions列表(里面是(row, col)元组)。
def check_winner(positions):
"""检查给定的棋子位置列表是否连成三子"""
# 将位置列表转换为按行、列、对角线检查的集合
rows = {}
cols = {}
for (r, c) in positions:
rows[r] = rows.get(r, 0) + 1
cols[c] = cols.get(c, 0) + 1
# 检查是否有行或列满3个
if 3 in rows.values() or 3 in cols.values():
return True
# 检查两条对角线
diag1 = sum(1 for (r,c) in positions if r == c) # 主对角线
diag2 = sum(1 for (r,c) in positions if r + c == 2) # 副对角线
return diag1 == 3 or diag2 == 3
这个函数只针对某一方的棋子位置进行判断,在AI决策时会反复用到。它比遍历所有方向稍微快一点,代码也更易读。
3.2 决策算法:从“菜鸟”到“高手”的进阶
一个像样的三子棋AI,至少应该具备以下几个层次的决策能力,我称之为决策金字塔:
- 第一层(进攻):检查自己是否有一步制胜的机会。即遍历所有空位,如果AI(白子)下在那里能立刻连成三子,就下那里。
- 第二层(防守):检查对手是否有一步制胜的机会。即遍历所有空位,如果玩家(黑子)下在那里能立刻连成三子,就必须堵住那里。
- 第三层(创造机会):在既不能赢也不用堵的情况下,选择一个对自己最有利的位置。比如,下在能让自己同时产生两个“活二”(再下一子就能赢的线路)的位置,或者抢占中心、角落等战略要点。
我们可以用一个简单的优先级评分来实现这个逻辑:
def ai_move(board):
"""AI决策函数,返回最优的行列坐标"""
# 优先级1: 自己能赢,马上赢
for r in range(3):
for c in range(3):
if board[r][c] == 0:
board[r][c] = 2 # 假设AI下这里
if check_winner(get_positions(board, 2)): # 检查AI是否赢
board[r][c] = 0 # 恢复棋盘
return r, c
board[r][c] = 0 # 恢复棋盘
# 优先级2: 对手能赢,必须堵
for r in range(3):
for c in range(3):
if board[r][c] == 0:
board[r][c] = 1 # 假设玩家下这里
if check_winner(get_positions(board, 1)): # 检查玩家是否赢
board[r][c] = 0
return r, c # AI下这里堵住
board[r][c] = 0
# 优先级3: 战略位置选择
# 定义一个简单的位置价值表,中心最值钱,角落次之,边再次之
position_score = [
[3, 2, 3],
[2, 4, 2],
[3, 2, 3]
]
best_score = -1
best_move = None
for r in range(3):
for c in range(3):
if board[r][c] == 0:
# 可以在这里加入更复杂的评估,比如计算这个位置能形成多少条“活二”
score = position_score[r][c]
if score > best_score:
best_score = score
best_move = (r, c)
return best_move # 返回价值最高的空位
这个AI虽然还远不及Minimax或Alpha-Beta剪枝等经典算法强大,但已经足够和普通人类玩家有来有回了。在OpenMV有限的算力下,实现这种启发式规则是性价比最高的选择。
4. 系统联调:串口通信与机械臂控制的实战要点
AI想好了下哪里,接下来就要通过串口告诉单片机,驱动机械臂去执行。这是从“数字世界”回到“物理世界”的关键一步,也是坑最多的地方。
4.1 设计一个可靠的通信协议
直接用uart.write(str(num))发送一个数字字符是最简单的,但在复杂的电磁环境下,很容易出错。我强烈建议设计一个简单的帧协议。一个经典的格式是:帧头 + 数据 + 校验和 + 帧尾。
例如,我们可以定义一帧数据为:0xAA(帧头) + 棋格编号(1-9) + 校验和(前面所有字节的和取低8位) + 0x55(帧尾)。
- 帧头帧尾:用于在数据流中识别一帧数据的开始和结束。
- 校验和:用于验证数据在传输过程中是否出错。单片机端收到后,会计算除帧头帧尾和校验和之外所有字节的和,与收到的校验和比对,不一致则丢弃这一帧,请求重发。
在OpenMV端,发送函数可以这样写:
def send_grid_number(num):
"""通过UART发送棋格编号,带简单协议"""
data_byte = num & 0xFF # 确保是一个字节
checksum = (0xAA + data_byte) & 0xFF
frame = bytearray([0xAA, data_byte, checksum, 0x55])
uart.write(frame)
print("[UART Sent] Grid:", num, "Frame:", frame)
在单片机(如STM32、Arduino)端,你需要编写相应的解析程序,不断读取串口数据,寻找0xAA,然后读取后续字节,验证校验和与帧尾,最后提取出有效的棋格编号。
4.2 机械臂坐标映射与动作序列
单片机收到“下在第5格”的指令后,需要控制机械臂完成“移动到棋子堆 -> 夹取棋子 -> 移动到第5格中心 -> 放下棋子”这一系列动作。这里的关键在于坐标映射。
你需要事先进行手眼标定(虽然对于这个项目,一个简单的比例映射可能就够了)。简单来说,就是先在图像上点击第1格和第9格的中心点,同时记录下机械臂在这两个位置时的关节角度或坐标。这样就能建立一个从图像像素坐标到机械臂工作空间坐标的线性映射关系。当AI给出逻辑格子编号后,OpenMV可以将其转换为图像中心坐标,再通过这个映射关系计算出机械臂的目标坐标,发送给单片机。
更实际的步骤是:
- 预标定:在程序初始化时,手动引导机械臂走到棋盘区域的四个角点或几个特征点,记录下这些点的图像坐标和机械臂坐标。
- 坐标转换:在代码里,使用这些点对,通过透视变换或仿射变换计算出转换矩阵。这样,对于图像上任何一个点,都能算出机械臂应该去的位置。
- 动作队列:机械臂的每一个完整动作(如夹取、移动、放下)都应该分解为一系列基本指令(如各舵机角度),并考虑动作之间的延时,避免过快导致抖动或失步。
4.3 调试技巧与抗干扰设计
在实际联调中,我遇到了几个典型问题:
- 问题一:串口数据丢失或错乱。解决方法:除了使用带校验的协议,还要在OpenMV端增加重发机制。如果一段时间内没收到单片机的应答(ACK),就重发上一次的指令。同时,适当降低波特率(如9600)往往比高波特率更稳定。
- 问题二:机械臂动作不到位。解决方法:一是检查电源是否充足,动作瞬间电流不足会导致舵机抖动;二是在每个关键动作后,增加一个状态反馈环节。比如,机械臂移动到目标位置后,用一个简单的光电传感器或限位开关确认一下,再执行下一步。
- 问题三:视觉与动作不同步。比如机械臂还没放下棋子,视觉系统就以为棋盘状态已经更新,导致AI基于错误棋盘思考。解决方法:引入状态机。系统可以有几个明确的状态:
WAITING_FOR_PLAYER(等待玩家落子)、DETECTING(视觉检测)、AI_THINKING(AI决策)、ROBOT_MOVING(机械臂执行)、WAITING_CONFIRM(等待动作完成确认)。只有收到机械臂“动作完成”的反馈后,系统才从ROBOT_MOVING状态切回DETECTING状态,开始新一轮检测。
5. 性能优化与项目进阶思考
当你把整个系统跑通,看着机械臂和你对战三子棋时,成就感绝对是爆棚的。但这只是开始,我们可以从以下几个方面让这个项目变得更专业、更健壮。
5.1 OpenMV端的性能压榨
OpenMV的算力有限,尤其是在跑图像处理的同时还要进行AI决策和通信。我们可以做这些优化:
- 降低分辨率:QQVGA (160x120) 对于这个项目已经足够,降低分辨率能大幅减少
find_blobs等函数的处理时间。 - 限制ROI:我们已经把视觉处理严格限制在棋盘区域内,这本身就是最大的优化。确保
roi参数设置正确,不要处理整张图。 - 减少不必要的绘制:
img.draw_circle,img.draw_string这些函数在调试时很有用,但在最终稳定运行时可以注释掉,能节省一些时间。 - 非阻塞式设计:主循环里,图像采集和处理是连续的,但AI决策和串口发送不必每帧都进行。可以设置一个标志位,只有当检测到棋盘状态发生变化(比如玩家落子)时,才触发AI决策流程。
5.2 提升视觉鲁棒性
- 应对光照变化:除了动态阈值,可以考虑使用直方图均衡化(
img.histeq())来增强图像对比度,或者尝试在红外光(如果OpenMV支持)下进行,以减少可见光环境的影响。 - 多帧投票决策:对于棋子有无的判断,不要相信单帧的结果。可以连续检测3-5帧,只有当超过一半的帧数都认为某个格子有黑子(或白子)时,才更新
board状态。这能有效过滤掉偶尔的识别抖动。 - 棋盘丢失重校准:虽然我们用了“一次校准”,但如果棋盘被严重遮挡或摄像头被碰歪了怎么办?可以增加一个逻辑:定期(比如每100帧)用很低的计算量粗略检查一下之前存储的
corners区域是否还是棋盘(例如检查区域平均颜色或边缘特征),如果丢失,则重新触发一次校准流程。
5.3 从三子棋到更复杂的博弈
这个项目的框架具有很强的扩展性。一旦你掌握了“视觉感知 -> 状态抽象 -> AI决策 -> 控制执行”这个闭环,就可以尝试更复杂的游戏,比如五子棋、国际象棋残局甚至简单的抓取积木并分类。
对于更复杂的游戏,AI决策算法需要升级。你可以研究Minimax算法及其优化版本Alpha-Beta剪枝,它们能在有限的搜索深度内找到最优解。虽然OpenMV的MicroPython环境计算能力有限,但对于五子棋的早期几步或简化棋盘的国际象棋,进行3-4层深度的搜索还是有可能的。这将会是对你算法设计和代码优化能力的极大挑战和锻炼。
最后,别忘了项目展示。用3D打印一个漂亮的棋盘底座,把OpenMV和机械臂整洁地固定好,再写一个简单的LCD显示屏界面来显示当前棋盘状态和胜负信息,一个充满极客魅力的智能对弈机器人就诞生了。这个过程里学到的,远不止是几行代码,而是解决一个完整软硬件结合问题的系统思维和工程能力。
509



被折叠的 条评论
为什么被折叠?



