ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

基于OpenCV的高鲁棒性三子棋视觉检测方案

基于OpenCV的高鲁棒性三子棋视觉检测方案 简介本资源是2024年全国大学生电子设计竞赛E题‘高鲁棒性三子棋视觉识别系统’的完整OpenCV视觉检测方案源码面向计算机、自动化、人工智能等专业参赛学生及项目实践者解决棋盘定位、棋子识别、姿态估计与状态判读等核心视觉任务。压缩包共31个文件含15个Python主控与算法模块如camera.py、quad_detector.py、chess.py等、9张实拍棋盘/棋子测试图、5张HSV调参与标签识别参考图以及README.md说明文档和.gitignore配置文件整体4.56MB结构清晰、模块解耦便于理解视觉流程与调试逻辑。已有620人学习下载代码经导师指导并获电赛国赛评审99分高分认可提供可直接运行的完整工程配套图像预处理、AprilTag辅助标定、多光照鲁棒性优化等实战细节小白亦可快速上手部署与二次开发。 做2024电赛E题的同学估计都有过这种时刻代码在自己电脑上跑得好好的一到比赛现场换个灯光、换张棋盘纸棋子识别就开始抽风。三子棋这个场景看起来简单——3x3棋盘、黑白两色棋子随便写几行OpenCV似乎都能跑。但正因为场景太简单视觉模块根本没有容错空间哪怕只错一个格子的判断整局对弈就废了。这篇博文讲的不是一个人闷头折腾出来的“特效算法”而是一套以“高鲁棒性”为第一目标的OpenCV视觉检测方案棋盘定位用四点透视矫正加先验标定棋子识别用HSV颜色空间加格子覆盖率判定外面再套一层光照归一化、ROI掩膜、多帧投票的保护壳。不管你是正在备赛电赛还是想快速搭一套可靠视觉识别底座都可以直接参考这套思路。1. 赛题现场最现实的难题视觉检测为什么是三子棋装置的命门三子棋对弈装置的基本链路是摄像头感知棋局状态主控跑对弈算法决定落子位置执行机构把棋子放到对应格子。这个链路里视觉部分负责的是整个系统的“眼睛”。眼睛看错了后面所有对弈逻辑都等于在瞎猜。很多同学一开始就把大量精力花在对弈策略上结果联调时发现视觉识别在真实环境下根本稳不住棋盘都定位不准更别提判断黑棋白棋了最后只能眼睁睁看着功能扣分。电赛现场的环境远比实验室复杂。灯光是顶灯加侧灯的混合光源色温忽冷忽热桌面可能反光棋盘纸可能是哑光的也可能是覆膜的。棋子是临时采购的亚克力圆片表面光滑得要命强光下会形成很亮的反光点。这些东西任何一个都可能打乱你在实验室里调好的阈值。说得直白一点你在实验室用普通打印纸和LED白光调好的那套参数到现场大概率直接失效。所以“高鲁棒性”不是一句口号而是由一系列工程手段堆出来的结果。它不等于上深度学习模型恰恰相反传统OpenCV方法只要把环境先验利用好在实时性和稳定性上完全可以胜任。高鲁棒性体现在几个方面棋盘找不到的时候不崩光线变化的时候不飘棋子反光的时候不误判偶尔一帧识别错了不影响最终结果。整篇文章后面的内容都是围绕这几个目标展开的。2. 环境与器件选型树莓派、USB摄像头和OpenCV版本的组合方案先说结论如果你不是对C特别熟建议用Python。电赛拼的是调试效率和现场应对能力Python配合OpenCV可以让你在十分钟内看到识别效果而C光编译就要折腾半天。性能方面1280x720分辨率下做透视变换、掩膜、形态学处理树莓派4B上Python也能跑到15fps左右足够用了。摄像头选普通的免驱USB摄像头就行别再摄像头上花太多钱。真正要关注的是这几个参数第一分辨率至少要有1280x720太低的话棋子边缘细节不够后面做边缘密度判断会吃亏第二最好支持手动关闭自动白平衡和自动曝光这个非常关键后面第五章会专门讲第三帧率不用追高25fps足够实际我们还会故意降频做多帧决策。主控板卡方面树莓派4B和Jetson系列都没问题。树莓派的优势是生态成熟散热和电源方案好搞Jetson的算力更强但功耗和散热复杂一点。如果手头只有OpenMV或者K210我建议尽早换方案不是说这些板子不行而是OpenCV生态带来的调试工具和算法储备太重要了比赛现场你很难有时间从头造轮子。OpenCV版本建议装4.5.x到4.8.x之间的稳定版千万不要碰5.x的预发布版本。装的时候直接用pip安装wheel包不要自己编译源码Ubuntu下如果通过apt装很容易装到很老的版本接口行为和习惯完全不一样。Windows和Ubuntu通用的安装命令是这样pip install opencv-python4.8.1.78 opencv-contrib-python4.8.1.78这里有个常见的坑如果你之前装过旧版NumPy导入OpenCV可能报错比如找不到numpy.core.multiarray这类错误。解决方法是把NumPy升到1.24以上或者直接装的时候让pip重新解析依赖。最后说分辨率策略。我的习惯是用1280x720采集然后根据标定的棋盘区域裁剪出ROI再在ROI内部缩放或直接处理。不要整帧扔给算法这样既慢又容易把背景里的杂物当成目标。棋盘区域在画面中占比越大识别越稳所以摄像头安装高度和角度要提前设计好让棋盘主体占画面的三分之二以上。3. 棋盘定位与透视矫正先让程序知道棋盘在哪3.1 基于边缘和外接轮廓的棋盘检测棋盘是整个视觉系统的坐标系原点。所有棋子位置的判定都建立在“棋盘区域定位准确”这个前提上。我的思路是先找棋盘外框做透视矫正再均分成九宫格。第一版实现里我会先转灰度、高斯模糊、Canny边缘检测然后找轮廓筛选出面积最大而且接近矩形的轮廓。这里有个关键细节Canny出来的边缘往往是断裂的棋盘外框被反光打断是常事。所以我会在Canny之后做一次膨胀把断裂的边缘连起来再用RETR_EXTERNAL方式找最外层轮廓能避开很多杂乱的内部线条干扰。def get_board_rect(gray): blurred cv2.GaussianBlur(gray, (5, 5), 0) edges cv2.Canny(blurred, 100, 200) edges cv2.dilate(edges, np.ones((3, 3), np.uint8), iterations2) contours, _ cv2.findContours(edges, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE) if not contours: return None max_cnt max(contours, keycv2.contourArea) peri cv2.arcLength(max_cnt, True) approx cv2.approxPolyDP(max_cnt, 0.02 * peri, True) if len(approx) 4 and cv2.contourArea(approx) 5000: return approx.reshape(4, 2) return NoneapproxPolyDP的epsilon参数我习惯设为周长的2%。太大会把四边形的角给磨掉太小又会保留很多锯齿点。筛选条件里的面积5000是一个经验值具体根据分辨率调整原则是比棋盘外框小一个量级的轮廓直接忽略。3.2 透视变换把棋盘拉正找到四个角点后用getPerspectiveTransform计算变换矩阵再用warpPerspective把棋盘区域映射到一个固定尺寸的正方形上。我这里固定输出900x900这样每个格子就是300x300数值好算调试也方便。def warp_board(frame, pts): dst np.float32([[0, 0], [899, 0], [899, 899], [0, 899]]) M cv2.getPerspectiveTransform(pts.astype(np.float32), dst) board cv2.warpPerspective(frame, M, (900, 900)) return board, M这里要提醒一点warpPerspective输出的尺寸越大每个格子能看到的棋子细节越多但计算量也越大。900x900对树莓派来说压力不大再往上拉到1200会让后续的几路判断都变慢没必要。3.3 自动检测失败时的保底方案手动标定自动找外框再稳也怕现场出现极端情况。比如棋盘外框被机械结构遮挡或者棋盘纸本身颜色过浅导致边缘对比度不够。我的建议是千万不要只依赖全自动检测一定要加一个手动标定模式作为保底。具体做法是第一次启动时把画面显示出来用鼠标点击棋盘四个角按顺序记录左上、右上、右下、左下然后生成透视变换矩阵并保存成homography.txt。之后的运行直接加载这个矩阵不再做自动检测。这个方案在比赛现场就是救命稻草因为棋盘位置一旦固定整个比赛过程中基本不会动花十秒钟手动标定一次换来的是全程稳定。就算做了手动标定我仍然建议代码里保留自动检测的分支。两者结合的逻辑是这样启动时先尝试自动检测失败则提示手动标定运行过程中每一帧都检测一次棋盘位置如果连续几帧检测不到就回退到上一次成功的透视矩阵如果长时间检测不到再发出“棋盘丢失请重新标定”的提示。这样既保留了灵活性又不会因为偶尔的丢帧导致系统崩溃。3.4 九宫格划分不能用死数字透视矫正拿到900x900的棋盘图后把每个格子裁出来。这里切记不要写死300这个数字而是用BOARD_SIZE // 3动态计算。因为如果以后你想把输出分辨率改成600或1200写死数字就意味着所有地方都要跟着改太容易出错了。CELL_SIZE 900 // 3 for row in range(3): for col in range(3): cell board[row*CELL_SIZE:(row1)*CELL_SIZE, col*CELL_SIZE:(col1)*CELL_SIZE]格子划分坐标要在整个程序里保持一致因为后面不管是识别棋子还是决策算法都以这个格子索引为基准。我个人习惯用(row, col)的元组做索引避免出现行列搞反的低级错误。4. 黑白棋子识别HSV掩膜和格子覆盖率判定的实战细节4.1 为什么非得用HSV而不是RGB很多同学拿到图片第一反应是看RGB通道的数值。实际调试下来你会发现RGB三个通道和光照强度高度相关同一个白色棋子在LED白光下是(220, 220, 220)到了暖色灯光下就变成(240, 210, 180)数值漂移非常大。但HSV空间把色调、饱和度、亮度分开之后黑白两种棋子在同一环境下的特征就稳定多了。黑色棋子的核心特征是亮度低也就是V通道值低。白色棋子的核心特征是饱和度低也就是S通道值低、V通道值高。这两个特征在HSV空间里是天然分开的比在RGB空间里硬扣阈值要稳得多。4.2 黑棋掩膜最简单的反而最可靠黑棋在棋盘格上其实特别好检测因为它和棋盘底色形成强烈的亮度对比。用inRange直接框V通道的低值区间就行。我在实际代码里是这样处理的def get_black_mask(hsv_cell): mask cv2.inRange(hsv_cell, (0, 0, 0), (180, 255, 95)) mask cv2.morphologyEx(mask, cv2.MORPH_OPEN, np.ones((3, 3), np.uint8)) return maskV通道阈值设在95左右是因为纯黑棋子在画面里受环境光影响并不会真的只有0而是大概在60到120之间浮动。这个阈值不能设太低否则棋子中间的暗纹会漏判也不能设太高否则棋盘上的深色条纹也会混进来。4.3 白棋掩膜单纯靠“亮”会翻车白色棋子的检测是所有方案里最容易出问题的点因为棋盘格子通常也是白的。如果你只是用亮度阈值把亮的地方都拎出来那白色格底和白色棋子会混在一起覆盖率根本分不出来。我试过很多办法纯色掩膜、轮廓检测、边缘密度判断都单独用过最后发现要把它们组合起来才稳。我的最终判定逻辑分两步第一步用HSV的亮度和饱和度做一个基础掩膜把“比较亮且低饱和”的区域捞出来这代表候选的白色物体。第二步计算这个格子里的边缘密度。棋子是圆形凸起会在表面形成明显的边缘和高光而棋盘格底部是平的边缘像素很少。所以如果一个格子既有大量“亮且低饱和”的像素又有比较高的边缘密度那才判定为白色棋子。def classify_cell(hsv_cell, gray_cell): h, w hsv_cell.shape[:2] black_mask get_black_mask(hsv_cell) black_ratio np.count_nonzero(black_mask) / (h * w) if black_ratio 0.08: return black _, sat_mask cv2.threshold(hsv_cell[:, :, 1], 60, 255, cv2.THRESH_BINARY_INV) _, val_mask cv2.threshold(hsv_cell[:, :, 2], 140, 255, cv2.THRESH_BINARY) white_candidate cv2.bitwise_and(sat_mask, val_mask) white_ratio np.count_nonzero(white_candidate) / (h * w) edges cv2.Canny(gray_cell, 50, 120) edge_ratio np.count_nonzero(edges) / (h * w) if white_ratio 0.25 and edge_ratio 0.03: return white return empty这里white_ratio 0.25这个要求不算苛刻因为一个棋子在格子里大约能覆盖30%到40%的面积。edge_ratio 0.03是防止把干净的格底误判成棋子如果棋盘纸本身有花纹这个阈值需要现场微调。4.4 覆盖率阈值别写在代码里覆盖率阈值不是一个固定的值它受摄像头高度、棋盘分辨率、棋子大小影响很大。同一颗棋子摄像头抬高10厘米覆盖率就可能从35%掉到25%。所以阈值最好放到配置参数里比如config.py或者params.json比赛现场直接改配置文件再运行不用动代码逻辑。我自己测试时常用的初始阈值是黑棋8%、白棋25%、边缘密度3%但每次换场地我都会从头标一遍。4.5 实时可视化调参窗口调试时一定要把中间结果显示出来不要只盯着最终真值。我会在窗口里把每个格子的黑棋覆盖率、白棋覆盖率、边缘密度和最终判定结果全部打印出来。这样现场出了问题你能一眼看出是哪个环节掉链子是掩膜切多了还是边缘阈值太严还是整个格子坐标偏了。如果只输出最后的“black/white/empty”一旦出错你就只能靠猜。def debug_show(cell_index, black_ratio, white_ratio, edge_ratio, label): print(f[{cell_index}] black{black_ratio:.3f} white{white_ratio:.3f} edge{edge_ratio:.3f} - {label})5. 高鲁棒性的关键防线光照归一化、ROI掩膜与多帧决策5.1 直方图均衡化不是一用就灵光照波动是现场最大的不可控因素。很多教程会告诉你用equalizeHist做直方图均衡化但如果你直接对整帧做大概率会翻车。原因在于全局均衡化会把画面里暗区域提亮、亮区域压暗如果画面里有一半是暗色的桌面背景棋盘区域的对比度反而会被破坏掉。正确做法是只对棋盘ROI区域做均衡化或者用更温和的自适应版本CLAHE也就是限制对比度自适应直方图均衡化。它能限制相邻区域的对比度拉伸幅度不会出现一整块区域过曝的情况。我在代码里是这样用的clahe cv2.createCLAHE(clipLimit2.0, tileGridSize(8, 8)) gray_board cv2.cvtColor(board, cv2.COLOR_BGR2GRAY) gray_board clahe.apply(gray_board)clipLimit2.0是个比较保守的值如果你发现画面对比度还是不够可以逐步调到3.0、4.0但超过5.0以后噪声会被明显放大反而影响后面的边缘检测。5.2 ROI掩膜把检测范围焊死在棋盘内这个操作看起来简单但对鲁棒性的提升非常明显。做法是把棋盘区域之外的所有像素直接置成黑色这样背景里任何乱七八糟的物体包括人手、桌面纹路、别组的摄像头都不可能干扰到棋子检测。mask np.zeros_like(gray_board) contour_pts np.array([[0, 0], [899, 0], [899, 899], [0, 899]], dtypenp.int32) cv2.fillPoly(mask, [contour_pts], 255) masked cv2.bitwise_and(gray_board, gray_board, maskmask)实际使用时这个ROI不是用固定坐标而是由透视变换矩阵计算出来的也就是棋盘标定完成之后ROI就自动确定了。这样就算棋盘在画面里稍微平移ROI也会跟着跑。5.3 多帧投票用时间换稳定性单帧识别无论怎么做都难免偶发错误但电赛对实时性的要求在毫秒级并不是很高一次正确落子决策的周期可能是几百毫秒。这就是多帧投票的应用空间。我的做法是维护一个长度为5的滑动窗口每一帧把9个格子的判定结果存进去只有某个格子连续5帧的判定结果完全一致才把这个结果作为最终状态输出。如果连续5帧结果有抖动就维持上一帧的输出。def update_board_state(history, current): result {} for idx in range(9): votes [frame[idx] for frame in history[-5:]] if votes.count(black) 5: result[idx] black elif votes.count(white) 5: result[idx] white elif votes.count(empty) 5: result[idx] empty else: result[idx] current.get(last_state, {}).get(idx, empty) return result这个策略带来的延迟大约是5帧以15fps算就是0.33秒完全可接受。但它能把偶发一帧的误判几乎清零。比赛现场最怕的不是识别慢而是识别结果跳来跳去多帧投票就是专门解决这个问题的。5.4 摄像头自动白平衡必须关掉这个问题我在多个队伍身上都见过。USB摄像头的自动白平衡在场景变化时会自动调整色彩偏移可能你这边棋盘刚摆好白平衡还在慢慢收敛白棋的颜色就变了。这会导致饱和度、亮度阈值全部失效。如果摄像头支持手动设置我强烈建议固定白平衡和曝光参数。OpenCV里可以通过cv2.VideoCapture的CAP_PROP_AUTO_WB和CAP_PROP_AUTO_EXPOSURE关掉自动模式。不同摄像头驱动对于属性的支持程度不一样有的能从0到1切换有的需要设置具体模式值。调试时要花几分钟摸清它的属性范围。cap cv2.VideoCapture(0) cap.set(cv2.CAP_PROP_AUTO_WB, 0) cap.set(cv2.CAP_PROP_AUTO_EXPOSURE, 0)如果你用的摄像头不支持手动关那就只能通过固定安装位置、固定光源方向来规避。比赛现场如果条件允许尽量用一个台灯固定打光把光照条件人为稳定下来。5.5 先验参数固化比赛现场不调算法只调配置把所有需要现场调整的参数都收敛到一个配置文件里是我整场比赛做得最正确的事。包括CLAHE的clipLimit、黑棋V阈值、白棋饱和度阈值、覆盖率阈值、多帧投票窗口长度。现场如果发现识别有问题只需要打印实时覆盖率数值然后改配置文件里的数字再重启程序。千万不要比赛现场一行一行改代码逻辑那样既不安全也容易引入新bug。6. 调试现场踩过的坑反光、白棋误判和镜头畸变6.1 坑一棋盘纸反光导致外框断裂第一次在现场联调时棋盘纸覆了一层膜顶灯一照外框部分区域泛白Canny检测到的边缘断成了好几段外接轮廓根本拼不起一个完整矩形。当时第一反应是调Canny阈值但调来调去发现反光区域本身的边缘就是弱的怎么调都补不回来。解决方式有两种。第一种是在Canny之前用形态学闭运算把断裂的边缘接上我在代码里用dilate迭代两次就是这个目的。第二种是彻底放弃边缘检测改用颜色定位比如在棋盘纸四角贴四个高饱和度的色块用inRange找色块再算角点。后者更稳但需要额外准备棋盘纸现场不一定有。所以我的方案保留了边缘检测为主、手动标定为补充的双保险。6.2 坑二白色棋子在强光下“隐身”白色棋子在白色格底上失效是大家反馈最多的场景。原因在于强光下棋盘格纸和棋子的亮度都接近255饱和度都接近0HSV特征完全重叠。单纯靠颜色掩膜根本分不开。后来我的解决办法是引入两个辅助特征一是镜面高光亚克力白棋在侧光下会产生非常亮的高光点虽然棋盘纸也会反光但棋子的高光更集中、形状更圆二是棋子边缘的阴影凸起物在侧光下必然会在底部留下一条阴影线这条阴影线在棋盘格上会形成一条暗的环形边缘。用Canny统计边缘密度就是这个思路的实现。实际测试中只要棋盘格的表面没有复杂花纹边缘密度能很好地区分开“平坦的棋盘纸”和“凸起的棋子”。6.3 坑三霍夫圆检测在棋子交界处输出多个圆一开始我也尝试过用HoughCircles检测棋子效果在棋子稀疏时还行一旦棋子靠得近两个圆的边缘重叠霍夫变换就会输出一堆假圆你还得写额外的逻辑去去重和分簇麻烦得很。所以我最终放弃了霍夫圆改用“格子覆盖率”的思路。这样一来棋子识别从“找圆”变成了“填格子”算法复杂度下降不少鲁棒性反而更高。只要棋子不超出格子边界覆盖率判定就基本不会翻车。6.4 坑四镜头畸变让边缘格子的坐标偏移普通USB摄像头的广角镜头畸变比想象中严重尤其是棋盘占满画面边缘时边缘格子会被拉歪。透视变换能矫正一部分但透视模型解决不了径向畸变。如果调试时发现中心格子正常、边缘格子总是偏那就要考虑畸变矫正了。最稳妥的做法是用OpenCV的棋盘格标定板算一次相机内参和畸变系数然后每帧先做undistort再去检测。但比赛现场不一定有打印好的标定板所以我更常用的办法是让棋盘占比不要太大把棋盘放在画面中心区域附近边缘畸变影响就会小很多。另一个办法是加宽棋盘外框的容错范围把透视变换的目标尺寸稍微做宽一点给边缘格子留出余量。6.5 调试节奏从可复现的日志开始最后分享一个调试节奏的建议。现场调试时我习惯先把所有中间数值用日志打出来包括每帧的棋盘矩形坐标、每格的黑白覆盖率、边缘密度和最终判定。然后固定一个已知棋局状态比如棋盘上摆好“黑棋在中心、白棋在左上角”让程序连续跑两分钟观察日志里有没有任何一帧出现判定跳变。如果有就回去看那一帧的覆盖率数据判断是阈值问题还是掩膜问题。这个过程重复几轮就能把大部分参数稳定下来。之后再切换到自动对弈模式让机械结构动起来观察连续决策是否有状态错乱。这套方案我在不同光照条件下、三套棋盘纸、两批不同规格的棋子上都跑过最终在现场一次通过。回头看真正起作用的不是某一个算法而是“先验标定、颜色掩膜、多帧投票、参数固化”这一整套工程约束手段。希望这些经验能帮你少走几条弯路三子棋视觉检测这个坎稳扎稳打是能迈过去的。本文还有配套的精品资源点击获取
返回列表