ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

Python随机赛事生成与SVG奖杯模板实践

Python随机赛事生成与SVG奖杯模板实践 一个能随机生成比赛并自动颁发奖杯的奖杯机器人放在学习环境里是练习随机化设计和图像输出的练手项目放在团队内部则是给周会、家庭活动或班级游戏增加仪式感的小工具。大雄的金牌梦这个标题带着明显的动漫味道但落到工程上要解决的问题非常具体赛事怎么随机生成结果如何排序奖杯图片如何按模板输出以及整个流程怎么一键跑完。这篇文章会把整条链路拆开先设计赛事数据和随机生成逻辑再实现一个基于 SVG 的奖杯模板最后用命令行入口把两块串起来形成一个完整可复现的项目。读完之后你可以自己运行一条命令生成一场随机赛事、得到所有选手名次并拿到对应的奖杯 SVG 文件。如果机器上安装了可选依赖还能自动转成 PNG方便直接发到群里或打印出来。1. 先想清楚奖杯机器人要解决的两个问题1.1 随机赛事生成器到底在做什么随机赛事生成器的职责可以从三个词拆开看随机、赛事、生成。“随机”不是毫无章法地乱来而是指在给定约束下让程序自动选择一个项目、打乱参赛者顺序、产生一个分数。约束来源于配置例如项目池里有哪些比赛、参赛者有哪些人、是否允许同分。“赛事”是数据的载体。一次赛事至少要包含赛事名称、比赛项目、参赛者、排名和分数。只有把这几个字段结构化后续生成奖杯才有数据来源。“生成”则是把随机选择的结果落成文件。典型输出包括选手排名 JSON、每个名次对应的奖杯 SVG以及便于核对的结果摘要。这个模块其实不依赖任何第三方库只用 Python 标准库里的 dataclass 和 random 就能实现。它最大的价值不是代码量而是把“随机”这件事控制住随机之后还要能复现、能排序、能解释。这比单纯调用一个random.choice要复杂也正是这篇文章要展开的部分。1.2 奖杯输出为什么选择 SVG 而不是直接画图奖杯输出有两种常见做法一种是用 PIL/Pillow 直接绘制位图另一种是拼 SVG 字符串再按需转位图。直接绘制位图的问题是代码量会快速膨胀。圆角矩形、渐变填充、文字居中、不同名次的配色、中文字体渲染这些在 Pillow 里每一项都要单独处理调试成本很高。尤其当你想改一下奖杯样式时坐标和绘画逻辑容易互相影响。SVG 的好处在于把“样式”和“数据”分开。奖杯的轮廓、渐变色、文字位置是模板赛事名、项目名、名次、选手姓名是填充数据。只要数据按规则替换进去几十行代码就能产出一张清晰、可缩放、可打印的奖杯图。方案优点缺点适用场景SVG 模板文本清晰、矢量缩放、样式集中、无需图像库查看需要浏览器或转换工具奖杯、奖状、证书、模板化输出Pillow 直接绘图输出 PNG 一步到位像素控制精确渐变、圆角、文字排版代码量大需要精细像素控制的图片HTML 渲染转图排版能力最强适合复杂布局依赖浏览器或无头渲染环境复杂版式海报因此这个项目的主输出采用 SVGPNG 作为可选增强能力。这样既保证核心代码能在纯净 Python 环境运行又保留了向图片转化的路径。1.3 随机种子决定结果能不能复现随机结果最大的痛点是不可复现。第一次运行生成胖虎第一第二次运行可能变成小夫第一明明代码没改结果却对不上。这在调试和写自动化测试时非常麻烦。Python 的random模块支持随机种子机制。只要给random.Random(seed)传入相同的种子后续在同一个随机实例上调用sample、shuffle、randint等操作都会得到相同序列。这里要强调一个关键点不要在项目里混用全局random模块和局部Random实例。如果一部分代码使用random.randint另一部分使用Random(seed)那么即使传了种子结果也会因为全局状态被其他模块影响而无法复现。本项目的做法是创建一个独立的rng random.Random(seed)所有随机操作都通过这个实例完成。seed不传时Random()会使用系统熵作为随机源传了固定值就能稳定复现。注意复现不是指每次运行顺序完全一样而是指在相同输入和相同 seed 下排名、分数、项目选择和结果摘要保持一致。2. 环境准备与项目结构2.1 运行环境与依赖这个项目对运行环境的要求很低核心功能只需要一个 Python 3.9 以上解释器因为代码中使用了dataclasses和现代类型标注。如果只输出 SVG不需要安装任何第三方库。如果需要把 SVG 转成 PNG才需要安装cairosvg。这个库依赖操作系统里的 cairo 库安装失败很常见所以把它设计成可选依赖而不是硬性依赖。项目要求说明Python3.9主要使用标准库pip任意可用版本用于安装可选依赖核心依赖无使用 dataclasses、random、argparse、json可选依赖cairosvg用于 SVG 转 PNG中文字体系统内有中文字体Windows 可使用 Microsoft YaHeiLinux 建议安装 fonts-noto-cjk创建虚拟环境后如果需要 PNG 能力执行python -m venv .venv source .venv/bin/activate pip install cairosvg2.7.1如果安装失败也不要卡在这里。项目会在转换失败时抛出警告并保留 SVG 文件。SVG 本身可以用浏览器直接打开不影响主要流程。2.2 项目目录与职责划分为了让文章保持可复现性项目按单一可运行目录组织gold-medal-bot/ ├── main.py ├── models.py ├── generator.py ├── svg_trophy.py ├── requirements.txt └── output/四个 Python 文件的职责非常明确models.py定义参赛者、比赛结果、赛事事件三个数据模型。generator.py实现随机赛事生成逻辑包括项目抽取、选手打乱、分数生成和排名。svg_trophy.py负责把一条比赛结果渲染成 SVG 奖杯并提供 SVG 转 PNG 的可选函数。main.py命令行入口把所有模块串起来生成 JSON 结果文件和奖杯文件。这样拆分的好处是每一层可以单独测试。修改奖杯样式不影响随机逻辑调整随机算法也不影响文件输出。对于这种小项目模块化依然值得坚持因为它能让后续扩展成本明显降低。3. 实现赛事数据模型和随机生成器3.1 用 dataclass 表达赛事数据数据模型是随机生成器和奖杯模板之间的接口。先用dataclass定义三个结构from dataclasses import dataclass, field from typing import List dataclass class Contestant: name: str team: str dataclass class MatchResult: rank: int contestant: Contestant score: float project: str dataclass class SportEvent: event_name: str projects: List[str] results: List[MatchResult] field(default_factorylist)Contestant承载参与者信息name是必填项team可以留空用于支持队伍场景。MatchResult是一条完整成绩记录包含名次、参赛者、分数和项目名。SportEvent是一次随机赛事的整体结果包含赛事名称、被抽中的项目列表以及所有项目的成绩记录。这里把team设计成可选字段是一个值得注意的小决策。如果一开始就把team写死成必填在个人趣味赛场景里每次都要传空字符串体验很差。用默认值允许调用方按需使用。3.2 随机生成器如何做到“随机但不混乱”生成器的核心是生成一组稳定的、可解释的随机结果。下面是一个完整的实现import random from typing import List, Optional from models import Contestant, MatchResult, SportEvent DEFAULT_PROJECTS [ 趣味障碍跑, 科学知识竞答, 积木搭建挑战, 编程闯关赛, 跳绳挑战, 记忆翻牌, 乒乓球颠球, 创意绘画, ] SCORE_RULES { 默认: (60, 100, int), 科学知识竞答: (0, 10, int), 创意绘画: (60, 99, float), } def _score_for(project: str, rng: random.Random) - float: rule SCORE_RULES.get(project, SCORE_RULES[默认]) low, high, kind rule if kind int: return float(rng.randint(low, high)) return round(rng.uniform(low, high), 1) def generate_event( event_name: str, contestants: List[str], projects: Optional[List[str]] None, seed: Optional[int] None, num_projects: int 1, ) - SportEvent: rng random.Random(seed) project_pool list(projects or DEFAULT_PROJECTS) if not project_pool: raise ValueError(项目池不能为空) picked_projects rng.sample( project_pool, kmin(num_projects, len(project_pool)) ) persons [ Contestant(namename.strip()) for name in contestants if name.strip() ] if len(persons) 2: raise ValueError(参赛者至少需要 2 人) event SportEvent(event_nameevent_name, projectspicked_projects) for project in picked_projects: shuffled persons[:] rng.shuffle(shuffled) scored [ MatchResult( rank0, contestantperson, score_score_for(project, rng), projectproject, ) for person in shuffled ] scored.sort(keylambda item: (-item.score, item.contestant.name)) for rank, item in enumerate(scored, start1): item.rank rank event.results.extend(scored) return event代码里有两个容易被忽略但是很关键的实现细节。第一所有随机操作都使用局部变量rng而不是全局random模块。这样可以避免别的模块调用random.seed或random.choice时污染当前项目的随机流。第二排名时使用了组合排序键(-item.score, item.contestant.name)。先按分数从高到低分数相同时按姓名排序。姓名排序在 Python 中不是拼音排序而是 Unicode 码点排序在中文环境下可能不是直观的“按首字母排序”。但在控制“同分顺序稳定”这个目标上它已经足够而且不依赖额外库。如果希望同分时仍然按拼音排需要引入拼音库或者为姓名增加拼音字段这会显著增加复杂度。对于本项目来说保持确定性的稳定排序才是重点。3.3 生成器参数速查generate_event是随机赛事生成器的核心函数参数不多但每个参数都直接影响输出结果。参数类型默认值作用说明event_namestr必填赛事名称会显示在奖杯顶部contestantsList[str]必填参赛者名单至少 2 人projectsOptional[List[str]]DEFAULT_PROJECTS项目池从池中随机抽取seedOptional[int]None随机种子相同种子可复现num_projectsint1要生成的项目数超过项目池长度时取全部SCORE_RULES是分数生成规则的字典目前只按项目区分整数分和小数分。实际使用时可以把规则放到外部配置文件里这样不改代码也能调整打分区间。这也是从“玩一玩”走向“生产工具”的重要一步。4. 实现 SVG 奖杯模板4.1 一份可以直接改的奖杯 SVG 模板奖杯生成模块把一条MatchResult渲染成一个 SVG 文件。这里的核心思想不是用代码绘图而是准备一份 SVG 模板字符串再把赛事名、项目名、名次和参赛者姓名替换进去。from models import MatchResult def _fit_text(text: str, max_len: int 12) - str: if len(text) max_len: return text return text[: max_len - 1] … def build_trophy_svg( event_name: str, result: MatchResult, output_path: str, ) - None: title _fit_text(event_name, 12) project _fit_text(result.project, 12) rank_text f第 {result.rank} 名 name_text _fit_text(result.contestant.name, 10) svg fsvg xmlnshttp://www.w3.org/2000/svg width800 height1000 viewBox0 0 800 1000 defs linearGradient idgold x10 y10 x21 y21 stop offset0% stop-color#ffe37a/ stop offset50% stop-color#f5b301/ stop offset100% stop-color#c77a00/ /linearGradient /defs rect x20 y20 width760 height960 rx30 fill#fffaf0 stroke#e6c76b stroke-width4/ text x400 y100 text-anchormiddle font-size40 font-familyNoto Sans CJK SC,Microsoft YaHei,sans-serif fill#333{title}/text text x400 y160 text-anchormiddle font-size30 font-familyNoto Sans CJK SC,Microsoft YaHei,sans-serif fill#888{project}/text ellipse cx400 cy260 rx150 ry24 fillurl(#gold)/ path dM 250 250
RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进