OpenCV自动生成比赛对阵图:布局算法与代码实战
简介这是一份结合OpenCV与数据库技术自动生成比赛对阵图的实战资源包面向需要处理赛事数据可视化、掌握C图像编程的开发者与学习者。资源基于C工程实现核心逻辑涵盖数据库连接、数据提取、对阵表绘制与文字渲染适合用作课程设计或实际项目参考。压缩包共28个文件主要包含cpp/h源码文件、可执行exe、调试符号pdb、字体ttf及工程配置dsw/dsp等源码与构建配置齐全便于二次编译与学习。已有1639人学习下载。通过该资源可以获得完整的比赛对阵图生成方案包括使用OpenCV绘制连线、矩形、文字结合数据库读取比赛信息并自动更新图形同时附带的simkai.ttf、wqy-zenhei.ttf字体文件解决了中文显示问题为处理中文标注场景提供了现成参考。 每次搞比赛最烦人的环节其实不是排赛程而是画那张对阵图。选手名单一出来得手动把名字一格一格填进去再拉线连上晋级关系稍微改动一下名单整张图又得重画一遍。后来我直接用OpenCV写了一个比赛对阵图自动生成的小工具输入一份选手名单跑一次脚本直接输出一张排版整齐的PNG对阵图轮次、连线、高亮冠军线全部自动搞定。这篇文章就完整记录一下这个项目的设计思路和代码实现适合有Python基础、做过一点OpenCV绘图或者正在做赛事工具的同学参考。1. 整体设计与思路拆解1.1 为什么选OpenCV而不是专门的绘图库提到生成结构图很多人第一反应是用graphviz或者networkx。graphviz在画树形图、拓扑图方面确实很强但它的布局风格偏自动排版对节点的位置控制不够自由。比赛对阵图这种场景轮次是自左向右固定排列的每一轮选手的上下顺序又有严格的种子逻辑我更希望每个像素都在自己掌控范围内这时候直接用OpenCV的绘图原语反而最顺手。OpenCV在大多数人的印象里是做人脸识别、轮廓检测、图像处理这些视觉任务的比如findContours、fillPoly这类接口大家很熟。但它的基础绘图能力其实被低估了cv2.line画线、cv2.rectangle画矩形、cv2.putText写文字、cv2.fillPoly填充色块这些原语组合起来足够应付很多数据可视化需求。而且OpenCV直接操作numpy数组画布就是一张图片矩阵后台上传、打印输出、二次加工都方便。另外一个现实因素很多做个比赛工具的场景项目里本来就已经装了OpenCV。比如有些人只是为了给报名表做点图像处理、给证书贴个照片就引入了OpenCV。既然依赖已经在了再为画个对阵图单独引一套graphviz还要处理dot语法和字体渲染反而增加维护成本。1.2 自动生成对阵图要解决的三个核心问题我把需求抽象为三点整个项目就是围绕这三个点展开的。第一个是布局计算。N个选手参赛第一轮应该有N/2场比赛第二轮N/4场直到决赛一场。所有节点在画布上要做得对称、均匀轮次从左到右推进选手名不能互相遮挡来自同一场晋级赛的两个选手下一轮要并到同一个父节点位置。这本质上是把一棵二叉树转换成平面坐标。第二个是连线逻辑。每一个对阵关系要用标准的水平线-垂直线-水平线把两个选手框连到下一个框上。这个连线看似简单但轮次多了以后线多了容易交叉间距控制不好就会糊成一团所以线的长度、转折点都要跟布局参数的步长严格绑定。第三个是视觉与可读性。白底深字、框线对齐、晋级线用不同颜色强调轮次顶部加Round 1、Semifinal、Final这样的标注最终冠军再加一个醒目的框。这些视觉元素直接决定这张图能不能拿去打印、发群。核心问题解决思路用到的主要OpenCV能力布局计算把比赛结构按2的幂次展开均匀分配Y坐标numpy数组坐标运算连线逻辑递归处理每轮配对关系标准三段式连线cv2.line视觉输出轮次标注、晋级高亮、冠军框cv2.putText、cv2.rectangle、cv2.fillPoly2. 关键算法与布局计算2.1 二叉树式的等比划分与坐标公式比赛对阵图本质上就是一棵倒过来的二叉树。每场比赛是两个子节点聚合到一个父节点冠军就是根节点。要让画面整齐最理想的做法是先把参赛人数补齐到2的幂次比如5个人参赛就按8人处理空位用轮空占位。这样整个树是满二叉树第一轮8个叶子节点第二轮4个第三轮2个第四轮1个画布上的排列会非常规整。Y坐标的分配是关键。第一个叶子节点放在画布顶部间距处最后一个放在底部间距处中间所有节点均匀分布。第i个节点的Y坐标公式是y_i margin_top i * (画布高度 - 上下边距) / (总节点数 - 1)父节点不需要重新随机分配直接取它两个孩子Y坐标的中点即y_parent (y_child1 y_child2) / 2这个公式的意义在于无论第一轮的两个选手相隔多远他们对应的第二轮节点必然位于两者正中间连线不会出现不必要的转折。后面的轮次依次向上归并画出来的图天然对称。另一个容易被忽略的问题是X坐标步长。每一轮的X方向间距必须足够容纳一个选手框加上一段水平连线的长度。如果选手名字比较长比如差不多先生战队这种X步长至少要250像素以上否则第二轮选手框会和第一轮的连线重叠。这个数值我会放在配置参数里实际使用时根据名单长度动态调整。2.2 画布尺寸自适应与文字宽度估算自动生成工具不能要求用户手动输入画布宽高最好根据参赛人数自动计算。这里有两个参数要动态决定画布高度和画布宽度。画布高度由第一轮选手数量决定。每个选手框大约高36像素加上框间距8个选手大约需要8乘以48等于384像素再加上上下边距画一个600乘800的画布就比较合适。16人参赛就要把高度撑到1200以上。如果用户给的是固定画布那我就反过来压缩选手框间距但这样做容易导致名字太挤所以我更建议前者动态生成画布尺寸。画布宽度由轮次数决定。一轮的X方向步长乘轮次数加上左边和右边的边距。例如步长2604轮比赛就是4乘以260加120大约1160像素宽。文字宽度计算有个细节OpenCV的cv2.putText自带字体是没办法直接测文字宽度的需要先画出来再看。但cv2.getTextSize这个接口可以拿到文字宽高。在画选手框之前我会先对每个选手名调用getTextSize如果文字宽度超过框宽就缩小字号重新测量如果名字实在太长就截断加省略号。英文和数字用这个方案没问题中文字符后面会单独处理。2.3 连线的标准化画法连线的标准做法是三段线从左边子节点的右侧中点先向右水平走到某个固定中转X坐标然后垂直移动到与父节点中心对齐的Y坐标再水平向右走到父节点的左侧中点。这个从中转点向上/向下走的方式和电路图里PCB布线很相似视觉上读起来很顺。我封装了一个draw_connector函数传入两个子节点的Y坐标、父节点的Y坐标、起止X坐标函数内部计算中转点然后画出三段线。这样做的好处是整个项目只有一处控制连线逻辑需要调整线宽、颜色、拐角直径时只改一个函数。连线的配色我用了两级非晋级阶段用深灰色线宽2像素八强到决赛这些关键轮次把相关连线改成高亮色线宽加到4像素。这个设计在视觉上能快速引导读者看比赛主线尤其适合赛程图用于展示的场景。3. 实操过程与核心代码实现3.1 环境准备与依赖说明这个项目只需要一个核心依赖opencv-python。安装很简单pip install opencv-python如果你的名单里有中文还需要Pillow配合渲染中文字体pip install pillow我这里故意没引入opencv-contrib-python因为contrib包里包含一堆我们根本用不到的扩展模块装起来体积更大没必要。另外给一个环境排查的提示如果不小心执行了import cv2报ModuleNotFoundError: No module named cv2优先检查当前命令行激活的Python环境是不是装包的那个环境。这个错误在Anaconda用户里非常常见我踩过几次基本都是环境搞混了。3.2 选手名单与轮次结构组织工具的输入是一个列表比如players [张三, 李四, 王五, 赵六, 钱七, 孙八, 周九, 吴十]首先要把人数补齐到2的幂次。这里用位运算循环比直接用math.log2更直观import math power 1 while power len(players): power 1 players list(players) [轮空] * (power - len(players)) rounds int(math.log2(power))然后我准备一个结构来保存每轮每个位置对应的选手。第一轮直接是补齐后的名单第二轮开始每两个相邻选手合并为一组胜者进入下一轮。实际生成对阵图时我们不需要真正去模拟比赛结果只需要把晋级结构画出来所以核心是两个数组一个保存当前轮的选手列表另一个保存所有轮次节点坐标。3.3 核心绘制代码下面是我整理过的核心代码结构整个脚本运行完会直接生成对阵图图片可以直接拿去用。注释写得不长但关键位置都标了原因。import cv2 import math import numpy as np from PIL import Image, ImageDraw, ImageFont def get_text_size_pil(text, font): tmp Image.new(RGB, (1, 1)) draw ImageDraw.Draw(tmp) bbox draw.textbbox((0, 0), text, fontfont) return bbox[2] - bbox[0], bbox[3] - bbox[1] def draw_cn_text(img, text, pos, font, color): img cv2.cvtColor(img, cv2.COLOR_BGR2RGB) pil_img Image.fromarray(img) draw ImageDraw.Draw(pil_img) draw.text((pos[0], pos[1]), text, fontfont, fill(color[2], color[1], color[0])) img cv2.cvtColor(np.asarray(pil_img), cv2.COLOR_RGB2BGR) return img def draw_connector(img, x_start, x_end, y_a, y_b, color(60, 60, 60), thickness2): # 标准的三段式连线水平、垂直、水平 mid_x x_start 20 mid_y (y_a y_b) // 2 cv2.line(img, (x_start, y_a), (mid_x, y_a), color, thickness) cv2.line(img, (mid_x, y_a), (mid_x, y_b), color, thickness) cv2.line(img, (mid_x, y_b), (x_end, y_b), color, thickness) def generate_bracket(players, output_filebracket.png, font_pathsimhei.ttf, use_cnTrue, canvas_widthNone, canvas_heightNone): power 1 while power len(players): power 1 players list(players) [轮空] * (power - len(players)) rounds int(math.log2(power)) margin_x, margin_y 60, 50 player_box_h 36 round_step_x 240 if canvas_height is None: canvas_height max(500, margin_y * 2 player_box_h * power) if canvas_width is None: canvas_width max(900, margin_x * 2 round_step_x * rounds 280) img np.full((canvas_height, canvas_width, 3), (250, 250, 250), dtypenp.uint8) font ImageFont.truetype(font_path, 20) if use_cn else None def compute_player_ys(count, height, top_margin, box_h): # 将 count 个节点均匀分布 step (height - 2 * top_margin - box_h) / max(count - 1, 1) return [top_margin i * step for i in range(count)] current_players players current_ys compute_player_ys(len(current_players), canvas_height, margin_y, player_box_h) for r in range(rounds): x0 margin_x r * round_step_x x_right x0 round_step_x - 40 next_players [] next_ys [] # 画本轮选手框与选手名 for i, player in enumerate(current_players): y0 int(current_ys[i]) cv2.rectangle(img, (x0, y0), (x0 180, y0 player_box_h), (60, 60, 60), 2) if use_cn: tw, th get_text_size_pil(player, font) text_pos (x0 90 - tw // 2, y0 (player_box_h - th) // 2) img draw_cn_text(img, player, text_pos, font, (20, 20, 20)) else: cv2.putText(img, player, (x0 12, y0 24), cv2.FONT_HERSHEY_SIMPLEX, 0.6, (20, 20, 20), 1, cv2.LINE_AA) # 计算下一轮位置并画连接线 for i in range(0, len(current_players), 2): y1 current_ys[i] y2 current_ys[i 1] y_parent (y1 y2) / 2 next_ys.append(y_parent) next_players.append(待定) draw_connector(img, x0 180, x_right, int(y1 player_box_h / 2), int(y2 player_box_h / 2)) # 把垂直汇合点接到父节点框 cv2.line(img, (x_right, int(y_parent)), (x_right 40, int(y_parent)), (60, 60, 60), 2) # 最后一轮额外画冠军框 if len(current_players) 2: y_final int(next_ys[0]) cv2.rectangle(img, (x0 round_step_x 40, y_final - 10), (x0 round_step_x 220, y_final player_box_h 10), (0, 0, 200), 3) current_players next_players current_ys compute_player_ys(len(current_players), canvas_height, margin_y, player_box_h) cv2.imwrite(output_file, img) print(f对阵图已生成: {output_file}) if __name__ __main__: players [张三, 李四, 王五, 赵六, 钱七, 孙八, 周九, 吴十] generate_bracket(players, output_filedemo_bracket.png)这里有几个细节要额外说一下。第一文字的垂直居中不能直接套公式。cv2.putText的坐标参数指的是文字基线的左下角不是文字框的左上角所以直接按框的顶坐标加一个固定偏移就可以。PIL的ImageDraw.text则不一样它按左上角定位用textbbox计算宽高后居中即可。两个方案我都写在代码里了推荐优先用PIL尤其涉及中文姓名时。第二compute_player_ys这个函数每一轮都重新算一遍Y坐标而不是直接沿用上一轮的next_ys。这样做的原因是如果第一轮有多个选手因为轮空导致位置不均第二轮直接取中点会让画面歪歪扭扭重新做一次均匀分布能保证每一轮都对齐到画布的上下边缘整体更美观。第三冠军框我用了一个单独的逻辑在最后一轮结束后把胜者位置画一个带红色边框的矩形视觉上就是在对阵图右边多了一个红色冠军框非常醒目。3.4 输出效果与实际调参跑完上面的脚本会得到一张白底、深色文字、灰色连线的对阵图。第一轮8个选手分列两两比赛第二轮4个待定第三轮2个最后一轮1个最右边红色冠军框。整个图层次分明可以直接发到群里或者打印成海报。实际使用中我一般会调整三个参数round_step_x决定轮次间距人少的时候调到200像素就足够人多且名字长的时候调到280以上player_box_h是选手框高度如果名字是某某战队-队员这种长串建议把框高加到44同时调整文字字号canvas_width通常是自动算的但如果用户指定了固定尺寸我会优先保证所有轮次放进画布再把round_step_x等比例压缩。4. 常见问题与排查技巧实录4.1 中文乱码与字体加载OpenCV自带的cv2.putText是不支持中文的写中文会直接变成问号。这是项目里遇到的第一个坑。解决的方案就是我代码里用的PIL辅助绘制把OpenCV的BGR图片转成RGB用PIL的ImageDraw.text把中文画上去再转回numpy数组。这一步必须有中文字体文件常见的位置是Windows系统里的C:/Windows/Fonts/simhei.ttf换成Mac的话可以用系统自带的PingFang.ttc。如果厂商要求不能依赖系统字体可以把字体文件放到项目目录里用相对路径加载部署到服务器时一起打包就行。另外要注意PIL的draw.text绘制中文时中英文混排的宽度计算会有点偏差实测下来在名字后面加两个空格能让整体居中更稳定。4.2 选手过多时文字溢出与交叉线16人以下的比赛上述代码基本不用改。但到32人以上两个问题会同时出现第一轮选手框太密名字垂直方向重叠连线交叉很难看清哪两个选手在比赛。第一个问题的解法是拉长画布而不是缩小字号。画布高度跟参赛人数是线性关系32人的画布高度至少要到1800像素竖版图看起来完全没问题。第二个问题则要从布局逻辑入手不能只靠画线函数调整。标准的锦标赛种子排序可以避免第一轮交叉例如8人时不是按报名顺序1-2、3-4、5-6、7-8配对而是按1-8、4-5、3-6、2-7这种蛇形排种的方式配对。如果用户有种子排名工具应该优先按排种顺序排列代码里只需要在生成选手列表前重新排序即可def snake_seed_order(n): # 返回按标准锦标赛种子排列的索引 order [0] * n # 这里实现 1-8, 4-5, 3-6, 2-7 的排序逻辑 # 完整代码略核心是递归对半拆分并交替插入左右半区 return order4.3 线宽和色块的问题还有一个容易被忽略的点OpenCV默认的cv2.line和cv2.rectangle画出来的线是实心的但在高分辨率缩略图场景下太细的线会显得断断续续。建议生成正式文件时统一用线宽2或3并开启抗锯齿OpenCV里画线没有直接的抗锯齿参数但cv2.line配合LINE_AA能提升一定质量。如果要填充色块高亮某个队伍用cv2.fillPoly填充多边形比用cv2.rectangle加厚更顺手尤其你后续想做不规则的晋级路径高亮时。5. 一些扩展方向和补充建议这个项目的核心代码也就100多行但扩展空间非常大。我后来给工具加了一个参数支持把晋级标记传给生成函数。比如四强名单出来了我可以给对应选手框填充浅黄色背景晋级线加粗生成的图就变成了实时进度版对阵图赛事公众号直接拿来用。实现方式很简单准备一个晋级名单set画选手框之前检查当前选手是否在集合里是则先cv2.rectangle填充一个浅色矩形再画边框和文字。另一个建议是可以把批量流程串起来。报名表是Excel导出一个list脚本自动读取跑一遍生成图片整个过程不需要人碰图像。如果再配合cv2.VideoWriter还能把每轮晋级过程做成简单动画适合比赛现场大屏展示。最后分享一个我自己在实际项目里总结的小经验用OpenCV做这类结构性绘图不要把目光局限在图像处理四个字上。我们平时遇到的数据可视化需求很多都能用最朴素的line、rectangle、fillPoly、putText组合完成。比起学习复杂的绘图框架语法把坐标计算的数学逻辑理清楚往往更重要。就像比赛对阵图核心难点从来不是OpenCV怎么画线而是怎么用二叉树坐标公式让画面不乱、不偏、不交叉。把这层想透了工具自然就好用了。本文还有配套的精品资源点击获取
