用 Python 画世界杯淘汰赛对阵树:以今日 32 强赛为例

世界杯进入淘汰赛后,赛程展示方式就变了。

小组赛适合用表格:

  • 哪个小组
  • 哪几支球队
  • 积分多少
  • 净胜球多少

但淘汰赛更适合用"路径图":

  • 谁对谁
  • 胜者进入哪一轮
  • 下一场可能遇到谁
  • 哪条半区更拥挤

这类结构如果只用文字写,会很乱。

比如今天 32 强赛有几场重点比赛:

  • 科特迪瓦 vs 挪威
  • 法国 vs 瑞典
  • 墨西哥 vs 厄瓜多尔

如果只是写成列表,信息量很低。

但如果画成对阵树,读者一眼就能看懂"这些比赛是淘汰赛路径的一部分"。

所以今天这篇文章不写传统球评,也不写分析报告。

我们用 Python 画一个简单的世界杯淘汰赛对阵树。

注意:本文只做赛程可视化,不涉及博彩、赔率、下注,也不做胜负预测。


一、实现目标

我们要写一个小脚本,实现 4 件事:

  1. 维护今日淘汰赛对阵数据
  2. 用 matplotlib 绘制比赛卡片
  3. 用线条表示晋级路径
  4. 保存成 PNG 图片

最终生成:

复制代码
worldcup_knockout_bracket.png

这个图片可以用于:

  • CSDN 技术文章
  • 世界杯赛程复盘
  • 公众号配图
  • 今日头条信息图
  • 自己的体育数据看板

这比把赛程复制成纯文字列表强一点。人类终于想起图形化展示不是摆设了,可喜可贺。


二、安装依赖

本文只需要 matplotlib

复制代码
pip install matplotlib

检查版本:

复制代码
python -c "import matplotlib; print(matplotlib.__version__)"

建议使用 Python 3.9 以上版本。


三、准备对阵数据

创建文件:

复制代码
worldcup_knockout_bracket.py

先准备今日比赛数据:

复制代码
matches = [
    {
        "id": "m1",
        "round": "Round of 32",
        "home": "科特迪瓦",
        "away": "挪威",
        "note": "锋线效率 vs 身体对抗",
    },
    {
        "id": "m2",
        "round": "Round of 32",
        "home": "法国",
        "away": "瑞典",
        "note": "强队破局能力测试",
    },
    {
        "id": "m3",
        "round": "Round of 32",
        "home": "墨西哥",
        "away": "厄瓜多尔",
        "note": "主场压力 vs 转换冲击",
    },
]

这里的 note 字段不是预测结果,只是比赛看点。

后续如果比赛结束,可以继续加字段:

复制代码
"home_score": 2,
"away_score": 1,
"winner": "法国"

这样就能从"赛前对阵树"扩展成"赛后晋级图"。


四、设计画布坐标

淘汰赛对阵树本质上就是:

  • 左侧:本轮比赛
  • 右侧:下一轮占位
  • 中间:晋级连线

我们先给每场比赛一个坐标。

复制代码
layout = {
    "m1": (0.1, 0.78),
    "m2": (0.1, 0.50),
    "m3": (0.1, 0.22),
    "next_1": (0.65, 0.64),
    "next_2": (0.65, 0.28),
}

其中:

  • m1、m2、m3 是今日 32 强赛
  • next_1、next_2 是下一轮占位卡片

为了演示,我们让:

  • m1m2 的胜者路径靠近 next_1
  • m3 单独连接到 next_2

真实完整 bracket 会更复杂,但本文先做一个最小可运行版。

一开始就想画完整 32 队大图,代码会迅速变成线条意大利面,调坐标调到怀疑人生。先做小图,活得久一点。


五、绘制比赛卡片

先写一个函数,负责绘制单张比赛卡片:

复制代码
def draw_match_card(ax, x, y, title, home, away, note):
    width = 0.36
    height = 0.16

    rect = plt.Rectangle(
        (x, y - height / 2),
        width,
        height,
        linewidth=1.5,
        edgecolor="#334155",
        facecolor="#f8fafc",
        zorder=2,
    )

    ax.add_patch(rect)

    ax.text(
        x + 0.02,
        y + 0.045,
        title,
        fontsize=9,
        color="#64748b",
        va="center",
    )

    ax.text(
        x + 0.02,
        y,
        f"{home}  vs  {away}",
        fontsize=12,
        fontweight="bold",
        color="#0f172a",
        va="center",
    )

    ax.text(
        x + 0.02,
        y - 0.045,
        note,
        fontsize=9,
        color="#475569",
        va="center",
    )

这里用到了:

  • plt.Rectangle 绘制卡片背景
  • ax.text 绘制文字
  • zorder 控制图层顺序

卡片内容包括:

  • 轮次
  • 对阵
  • 看点说明

六、绘制晋级连线

接下来写一个连线函数:

复制代码
def draw_connector(ax, start, end):
    start_x, start_y = start
    end_x, end_y = end

    ax.plot(
        [start_x, end_x],
        [start_y, end_y],
        color="#94a3b8",
        linewidth=1.8,
        zorder=1,
    )

    ax.scatter(
        [end_x],
        [end_y],
        s=18,
        color="#2563eb",
        zorder=3,
    )

这个函数只画一条简单直线。

正式 bracket 里可以画成折线,比如:

复制代码
水平线 → 垂直线 → 水平线

但入门版本先用直线就够了。

别一开始就追求"像官方赛事图一样优雅",通常结果是图没画完,人先优雅地破防。


七、完整代码

下面是完整可运行代码:

python 复制代码
from pathlib import Path

import matplotlib.pyplot as plt


def setup_chinese_font() -> None:
    """
    设置中文字体。
    如果中文显示异常,可以根据系统修改字体名称。
    """
    plt.rcParams["font.sans-serif"] = [
        "Microsoft YaHei",
        "SimHei",
        "Arial Unicode MS",
        "DejaVu Sans",
    ]
    plt.rcParams["axes.unicode_minus"] = False


def draw_match_card(ax, x, y, title, home, away, note):
    """
    绘制比赛卡片。
    """
    width = 0.36
    height = 0.16

    rect = plt.Rectangle(
        (x, y - height / 2),
        width,
        height,
        linewidth=1.5,
        edgecolor="#334155",
        facecolor="#f8fafc",
        zorder=2,
    )

    ax.add_patch(rect)

    ax.text(
        x + 0.02,
        y + 0.045,
        title,
        fontsize=9,
        color="#64748b",
        va="center",
    )

    ax.text(
        x + 0.02,
        y,
        f"{home}  vs  {away}",
        fontsize=12,
        fontweight="bold",
        color="#0f172a",
        va="center",
    )

    ax.text(
        x + 0.02,
        y - 0.045,
        note,
        fontsize=9,
        color="#475569",
        va="center",
    )


def draw_next_round_card(ax, x, y, title):
    """
    绘制下一轮占位卡片。
    """
    width = 0.26
    height = 0.12

    rect = plt.Rectangle(
        (x, y - height / 2),
        width,
        height,
        linewidth=1.5,
        linestyle="--",
        edgecolor="#2563eb",
        facecolor="#eff6ff",
        zorder=2,
    )

    ax.add_patch(rect)

    ax.text(
        x + width / 2,
        y,
        title,
        fontsize=11,
        fontweight="bold",
        color="#1d4ed8",
        ha="center",
        va="center",
    )


def draw_connector(ax, start, end):
    """
    绘制晋级路径连线。
    """
    start_x, start_y = start
    end_x, end_y = end

    ax.plot(
        [start_x, end_x],
        [start_y, end_y],
        color="#94a3b8",
        linewidth=1.8,
        zorder=1,
    )

    ax.scatter(
        [end_x],
        [end_y],
        s=18,
        color="#2563eb",
        zorder=3,
    )


def draw_bracket(matches, output_file: str) -> None:
    """
    绘制世界杯淘汰赛对阵树。
    """
    setup_chinese_font()

    fig, ax = plt.subplots(figsize=(11, 7))

    ax.set_xlim(0, 1)
    ax.set_ylim(0, 1)
    ax.axis("off")

    ax.text(
        0.5,
        0.95,
        "世界杯 32 强淘汰赛对阵树",
        fontsize=18,
        fontweight="bold",
        ha="center",
        color="#0f172a",
    )

    ax.text(
        0.5,
        0.90,
        "示例:今日三场 Round of 32 比赛路径可视化",
        fontsize=11,
        ha="center",
        color="#64748b",
    )

    layout = {
        "m1": (0.08, 0.76),
        "m2": (0.08, 0.50),
        "m3": (0.08, 0.24),
        "next_1": (0.66, 0.63),
        "next_2": (0.66, 0.24),
    }

    for match in matches:
        x, y = layout[match["id"]]

        draw_match_card(
            ax=ax,
            x=x,
            y=y,
            title=match["round"],
            home=match["home"],
            away=match["away"],
            note=match["note"],
        )

    draw_next_round_card(ax, *layout["next_1"], title="胜者进入下一轮")
    draw_next_round_card(ax, *layout["next_2"], title="胜者进入下一轮")

    # 比赛卡片右侧出口坐标
    m1_exit = (0.08 + 0.36, 0.76)
    m2_exit = (0.08 + 0.36, 0.50)
    m3_exit = (0.08 + 0.36, 0.24)

    # 下一轮卡片左侧入口坐标
    next_1_entry = (0.66, 0.63)
    next_2_entry = (0.66, 0.24)

    draw_connector(ax, m1_exit, next_1_entry)
    draw_connector(ax, m2_exit, next_1_entry)
    draw_connector(ax, m3_exit, next_2_entry)

    ax.text(
        0.5,
        0.06,
        "说明:本文只做赛程可视化示例,不预测胜负,不涉及博彩、赔率或下注。",
        fontsize=10,
        ha="center",
        color="#64748b",
    )

    output_path = Path(output_file)
    fig.tight_layout()
    fig.savefig(output_path, dpi=180, bbox_inches="tight")
    plt.close(fig)

    print(f"淘汰赛对阵树已生成:{output_path.resolve()}")


def main() -> None:
    matches = [
        {
            "id": "m1",
            "round": "Round of 32",
            "home": "科特迪瓦",
            "away": "挪威",
            "note": "锋线效率 vs 身体对抗",
        },
        {
            "id": "m2",
            "round": "Round of 32",
            "home": "法国",
            "away": "瑞典",
            "note": "强队破局能力测试",
        },
        {
            "id": "m3",
            "round": "Round of 32",
            "home": "墨西哥",
            "away": "厄瓜多尔",
            "note": "主场压力 vs 转换冲击",
        },
    ]

    draw_bracket(
        matches=matches,
        output_file="worldcup_knockout_bracket.png",
    )


if __name__ == "__main__":
    main()

八、运行脚本

执行:

复制代码
python worldcup_knockout_bracket.py

运行后会生成:

复制代码
worldcup_knockout_bracket.png

如果中文显示成方块,修改字体配置:

复制代码
plt.rcParams["font.sans-serif"] = [
    "Microsoft YaHei",
    "SimHei",
    "Arial Unicode MS",
    "DejaVu Sans",
]

Windows 推荐:

复制代码
"Microsoft YaHei"

macOS 可以尝试:

复制代码
"Arial Unicode MS"

Linux 建议先安装中文字体。


九、改成读取 JSON 数据

如果不想把赛程写死在 Python 里,可以创建 matches.json

复制代码
[
  {
    "id": "m1",
    "round": "Round of 32",
    "home": "科特迪瓦",
    "away": "挪威",
    "note": "锋线效率 vs 身体对抗"
  },
  {
    "id": "m2",
    "round": "Round of 32",
    "home": "法国",
    "away": "瑞典",
    "note": "强队破局能力测试"
  },
  {
    "id": "m3",
    "round": "Round of 32",
    "home": "墨西哥",
    "away": "厄瓜多尔",
    "note": "主场压力 vs 转换冲击"
  }
]

然后增加读取函数:

复制代码
import json
from pathlib import Path


def load_matches(file_path: str):
    path = Path(file_path)

    if not path.exists():
        raise FileNotFoundError(f"文件不存在:{file_path}")

    with path.open("r", encoding="utf-8") as file:
        data = json.load(file)

    if not isinstance(data, list):
        raise ValueError("matches.json 顶层必须是数组")

    return data

修改 main()

复制代码
def main() -> None:
    matches = load_matches("matches.json")

    draw_bracket(
        matches=matches,
        output_file="worldcup_knockout_bracket.png",
    )

这样每天只需要更新 JSON 文件,不用改绘图逻辑。


十、加入赛果和晋级球队

比赛结束后,可以把数据改成这样:

复制代码
{
  "id": "m2",
  "round": "Round of 32",
  "home": "法国",
  "away": "瑞典",
  "home_score": 2,
  "away_score": 1,
  "winner": "法国",
  "note": "法国晋级下一轮"
}

然后在卡片里显示比分:

复制代码
def format_match_title(match):
    home = match["home"]
    away = match["away"]

    if "home_score" in match and "away_score" in match:
        return f"{home} {match['home_score']} - {match['away_score']} {away}"

    return f"{home}  vs  {away}"

把原来这行:

复制代码
f"{home}  vs  {away}"

替换成:

复制代码
format_match_title(match)

这样脚本可以从"赛前对阵图"升级成"赛后晋级图"。


十一、扩展成完整 32 强 bracket

完整 32 强对阵树需要更多信息:

复制代码
rounds = {
    "Round of 32": [...],
    "Round of 16": [...],
    "Quarter-finals": [...],
    "Semi-finals": [...],
    "Final": [...]
}

每一轮都可以用类似结构:

复制代码
{
  "id": "r32_m1",
  "home": "加拿大",
  "away": "南非",
  "winner": "加拿大",
  "next_match": "r16_m1"
}

然后根据 next_match 自动连线。

核心思路是:

  1. 每场比赛有唯一 ID
  2. 每场比赛知道自己通向哪一场
  3. 绘图时根据 ID 找坐标
  4. 用连线连接当前比赛和下一轮比赛

这是一个很适合继续扩展的小项目。

后续可以做成:

  • 静态 bracket 图片
  • Web 页面
  • Streamlit 看板
  • Vue/React 对阵表组件
  • 自动更新的淘汰赛页面

技术内容终于不只是"把比赛写成一段话",而是开始有点工程味了。


十二、为什么不用 Graphviz?

Graphviz 很适合画图结构,但本文选择 matplotlib,有两个原因:

第一,安装门槛低。

很多环境里直接安装 matplotlib 就能跑。

第二,样式可控。

比赛卡片、字体、颜色、线条、说明文字,都可以手动调整。

如果你想做更复杂的结构图,也可以用 Graphviz。

安装:

复制代码
pip install graphviz

但注意,很多系统还要额外安装 Graphviz 程序本体。

这就是它麻烦的地方。

Python 包装好了,系统依赖还在后面冷笑。开发者的一生就是这样,不是在装依赖,就是在怀疑依赖。


十三、可以怎么继续优化?

1. 自动生成文件名

复制代码
from datetime import date

output_file = f"worldcup_bracket_{date.today().isoformat()}.png"

这样每天生成不同图片,不会覆盖旧文件。

2. 按比赛状态改变颜色

未开赛:

复制代码
facecolor = "#f8fafc"

已结束:

复制代码
facecolor = "#ecfdf5"

爆冷或点球:

复制代码
facecolor = "#fef3c7"

3. 显示比赛时间

给数据加字段:

复制代码
"kickoff": "2026-06-30 17:00"

卡片里显示:

复制代码
ax.text(..., kickoff)

4. 生成中文和英文双版本

可以把 homeaway 拆成:

复制代码
"home_cn": "法国",
"home_en": "France"

根据参数输出不同语言版本。

5. 做成命令行工具

复制代码
python worldcup_knockout_bracket.py --input matches.json --output bracket.png

argparse 接参数:

复制代码
import argparse

这样就更像正式工具。


十四、总结

本文用 Python 和 matplotlib 实现了一个简单的世界杯淘汰赛对阵树可视化工具。

它完成了几件事:

  • 维护 32 强赛对阵数据
  • 绘制比赛卡片
  • 绘制晋级路径连线
  • 保存为 PNG 图片
  • 支持后续改成 JSON 输入
  • 支持扩展成完整淘汰赛 bracket

以今日 32 强赛为例:

  • 科特迪瓦 vs 挪威
  • 法国 vs 瑞典
  • 墨西哥 vs 厄瓜多尔

这些比赛如果只写成列表,信息很平。

但画成对阵树之后,读者更容易理解淘汰赛路径。

世界杯热点不一定只能写球评。

也可以用 Python 把赛程变成图。

对 CSDN 来说,这种内容更适合:

有代码。

有图表。

有真实场景。

还能继续扩展成项目。

这才是技术人追世界杯热点的合理方式。


轻量补充

如果你长期使用 ChatGPT、Claude、Gemini、Cursor 或 Kiro 这类 AI 工具做代码生成、图表解释、文章改写和数据整理,也可以把 gpt68.com 作为第三方 AI 会员充值平台入口之一了解。它解决的是 AI 工具订阅充值流程问题,不是替代工具本身。使用前建议看清套餐说明、到账说明和售后规则。