20242215 2024-2025-2 《Python程序设计》实验四报告
课程:《Python程序设计》
班级: 2422
姓名: 贾瑞宁
学号:20242215
实验教师:王志强
实验日期:2025年5月14日
必修/选修: 公选课
一、设计思路概述
-
本游戏的灵感来源于今年3月14日(国际圆周率日,国际数学日)见到的一个庆祝用的网页小游戏。
-
程序的主要设计思路是:记录用户手绘圆的轨迹,通过数学方法拟合一个标准的圆(计算残差并调用非线性最小二乘函数),计算出这个拟合圆的周长,与用户手绘圆的周长进行比较,进而对用户手绘圆的标准性做出判断(评级),并在排行榜中记录各次的评级。
二、文献研究和调研
(一)Thinker库
-
tkinter是Python的标准GUI(图形用户界面)库,用于创建图形用户界面应用程序。它提供了各种控件,如按钮、标签、文本框等,以及事件处理机制,使得开发者可以轻松地构建桌面应用程序。
-
CSDN博客:《Thinker》
-
重点关注学习以下章节:
-
Label & Button 标签和按钮
-
Entry & Text输入,文本框
-
Listbox列表部件
-
Canvas画布
-
Frame框架
-
messagebox弹窗
-
pack grid place 放置位置
(二)Numpy包
-
CSDN博客:《Python之Numpy详细教程》
-
NumPy是一个Python包。它代表“Numeric Python”。它是一个由多维数组对象和用于处理数组的例程集合组成的库。
-
使用NumPy,开发人员可以执行以下操作:
-
数组的算数和逻辑运算;
-
傅立叶变换和用于图形操作的例程;
-
与线性代数有关的操作(NumPy拥有线性代数和随机数生成的内置函数)。
(三)最小二乘函数least_squares
-
CSDN博客:《Python中最小二乘法least_squares的调用及参数说明》
-
least_squares是SciPy库中的一个函数,用于求解非线性最小二乘问题。它通过最小化目标函数的平方和来找到最佳拟合参数。
-
在优化问题中,least_squares特别适用于拟合曲线、曲面或其他模型到数据点。
(四)数据库
《零基础学Python》第十一章:使用Python操作数据库
三、概要设计
创建开始窗口
分析圆函数
残差函数
四、完整程序
'''
游戏设计:手绘圆的标准性判断
版本:vol.12
作者:20242215 贾瑞宁
'''
import tkinter as tk
import numpy as np
import math
from scipy.optimize import least_squares
import random
from tkinter import ttk
import sqlite3
import datetime
class CircleDrawingApp:
def __init__(self, root):
self.root = root # 创建画布 root: Tkinter的根窗口对象。
# 设置窗口标题
self.root.title("绘制圆估算π")
# 创建开始界面
self.start_frame = tk.Frame(root)
self.start_frame.pack(fill=tk.BOTH, expand=True) # 创建一个Frame(框架)作为开始界面,并使用pack方法将其放置在根窗口中
self.start_label = tk.Label(self.start_frame, text="★★★画圆小游戏★★★", font=("Microsoft YaHei", 24))
self.start_label.pack(pady=20) # 添加一个标签Label显示游戏标题“★★★画圆小游戏★★★”
self.rules_label = tk.Label(self.start_frame,
text="\n游戏规则:\n1. 使用鼠标在画布上绘制一个圆。\n2. 绘制完成后,程序将估算π值并显示评级。\n3. 尽量绘制一个完美的圆,以获得更高的评级。\n PS: 请尽量使圆首尾相接,且不要画的太小^_^ ",
font=("Microsoft YaHei", 16))
self.rules_label.pack(pady=20) # 添加另一个标签Label显示游戏规则
self.start_button = tk.Button(self.start_frame, text="开始游戏", font=("Microsoft YaHei", 16),
command=self.start_game)
self.start_button.pack(pady=20) # 添加一个按钮Button,点击后调用start_game方法开始游戏
# 创建游戏界面
self.game_frame = tk.Frame(root) # 创建另一个Frame作为游戏界面,但初始时不显示
self.canvas = tk.Canvas(self.game_frame, width=600, height=400, bg="white")
self.canvas.grid(row=0, column=0, columnspan=2) # 使用grid布局创建一个画布Canvas,用于绘制圆
self.result_label = tk.Label(self.game_frame, text="画一个圆,松开鼠标查看结果")
self.result_label.grid(row=1, column=0, columnspan=2) # 使用grid布局创建一个标签Label,用于显示游戏结果
self.clear_button = tk.Button(self.game_frame, text="清空", command=self.clear_canvas)
self.clear_button.grid(row=2, column=0, padx=5, pady=5) # 使用grid布局创建一个按钮Button,点击后调用clear_canvas方法清空画布
self.leaderboard_button = tk.Button(self.game_frame, text="排行榜", command=self.show_leaderboard)
self.leaderboard_button.grid(row=2, column=1, padx=5, pady=5) # 使用grid布局创建另一个按钮Button,点击后调用show_leaderboard方法显示排行榜
# 初始化变量
self.drawing = False # 标记是否正在绘制
self.points = [] # 存储绘制的点的坐标
self.line_id = None # 存储线条的ID
self.next_color = self.random_color() # 初始化下一个颜色,通过random_color方法生成这个随机颜色
# 初始化数据库
self.init_db() # 调用init_db方法初始化数据库,用于储存每次绘制的结果,并做成排行榜(可以在关闭后仍保存)
# 绑定事件
self.canvas.bind("<ButtonPress-1>", self.start_drawing) # 绑定鼠标按下事件<ButtonPress-1>,调用self.start_drawing方法开始绘制
self.canvas.bind("<B1-Motion>", self.draw) # 绑定鼠标移动事件<B1-Motion>,调用self.draw方法绘制线条
self.canvas.bind("<ButtonRelease-1>", self.stop_drawing) # 绑定鼠标释放事件<ButtonRelease-1>,调用self.stop_drawing方法停止绘制
# 显示开始界面
self.show_start_screen()
# 开始炫彩变色效果
self.change_color()
def show_start_screen(self):
self.start_frame.pack(fill=tk.BOTH, expand=True)
self.game_frame.pack_forget()
def start_game(self):
self.start_frame.pack_forget()
self.game_frame.pack(fill=tk.BOTH, expand=True)
def start_drawing(self, event):
self.drawing = True # 设置绘制状态为True
self.points = [(event.x, event.y)] # 初始化点列表
self.line_id = self.canvas.create_line(event.x, event.y, event.x, event.y, smooth=True,
fill=self.next_color) # 创建线条
def draw(self, event):
if self.drawing:
# 添加当前点并更新线条
self.points.append((event.x, event.y))
self.canvas.coords(self.line_id, *self.get_smoothed_points()) # 更新线条坐标
# 画完圆,进行分析,并生成下一个随机颜色
def stop_drawing(self, event):
if self.drawing:
self.drawing = False # 设置绘制状态为False
self.analyze_circle() # 调用分析圆的方法
self.next_color = self.random_color() # 生成下一个颜色
#分析圆
def analyze_circle(self):
# 检测是否画的太小
if len(self.points) < 3:
self.result_label.config(text="至少需要3个点来拟合圆")
return
# 拟合圆
try:
# 初始猜测
initial_guess = [0, 0, 1] # [x_center, y_center, radius]
# 定义残差函数
def residual_function(params):
x_center, y_center, radius = params
residuals = []
for x, y in self.points:
residuals.append((x - x_center) ** 2 + (y - y_center) ** 2 - radius ** 2)
return residuals
# 使用非线性最小二乘法拟合圆
result = least_squares(residual_function, initial_guess)
x_center, y_center, radius = result.x
except Exception as e:
self.result_label.config(text=f"错误: {str(e)}")
return
# 计算实际周长(包含首尾连接)
circumference = 0.0
for i in range(len(self.points)):
x1, y1 = self.points[i]
x2, y2 = self.points[(i + 1) % len(self.points)] # 自动闭合路径
circumference += np.hypot(x2 - x1, y2 - y1)
# 检查拟合圆的周长是否超过绘制轨迹长度的1.5倍(超过则判定画的不是圆)
if circumference * 1.5 < 2 * math.pi * radius:
self.result_label.config(text="请确认您绘制的是否是圆")
return
# 计算参数
diameter = 2 * radius # 直径
if diameter == 0:
self.result_label.config(text="错误: 直径为零")
return
calculated_pi = abs(circumference / diameter) # 计算π的近似值
error_percent = abs(calculated_pi - math.pi) / math.pi * 100 # 计算误差百分比
# 评级
grade = self.calculate_grade(error_percent)
# 检查误差是否过大(圆是否过于离谱)
if grade == "F":
self.result_label.config(text="请确认您绘制的是否是圆")
return
# 保存成绩和时间
current_time = datetime.datetime.now().strftime("%Y-%m-%d %H:%M:%S")
self.save_score(current_time, calculated_pi, error_percent, grade)
# 显示结果
self.result_label.config(
text=f"估算π值: {calculated_pi:.4f}\n"
f"误差: {error_percent:.2f}%\n"
f"评级: {grade}"
)
# 绘制拟合圆
self.canvas.create_oval(
x_center - radius, y_center - radius,
x_center + radius, y_center + radius,
outline=self.next_color, width=4
)
def calculate_grade(self, error_percent):
# 如果错误率小于3.0,返回等级"A+"
if error_percent < 3.0:
return "A+"
# 如果错误率小于5.0,返回等级"A-"
elif error_percent < 5.0:
return "A-"
# 如果错误率小于8.0,返回等级"B+"
elif error_percent < 8.0:
return "B+"
# 如果错误率小于10.0,返回等级"B-"
elif error_percent < 10.0:
return "B-"
# 如果错误率小于15.0,返回等级"C"
elif error_percent < 15.0:
return "C"
# 如果错误率小于20.0,返回等级"D"
elif error_percent < 20.0:
return "D"
# 如果错误率小于60.0,返回等级"E"
elif error_percent < 60.0:
return "E"
# 如果错误率大于或等于60.0,返回等级"F"
else:
return "F"
# 使用贝塞尔曲线平滑算法,用于平滑绘制的曲线,使其更好看(否则圆的边缘锯齿化很明显)
def get_smoothed_points(self):
if len(self.points) < 4:
return [p for point in self.points for p in point] # 首先检查点的数量是否少于4个。如果少于4个点,直接返回这些点,因为无法形成贝塞尔曲线
smoothed_points = [] # 创建一个空列表 smoothed_points 来存储平滑后的点。
for i in range(1, len(self.points) - 2):
p0, p1, p2, p3 = self.points[i - 1], self.points[i], self.points[i + 1], self.points[i + 2]
t_values = np.linspace(0, 1, 20)
for t in t_values:
x = (1 - t) ** 3 * p0[0] + 3 * (1 - t) ** 2 * t * p1[0] + 3 * (1 - t) * t ** 2 * p2[0] + t ** 3 * p3[0]
y = (1 - t) ** 3 * p0[1] + 3 * (1 - t) ** 2 * t * p1[1] + 3 * (1 - t) * t ** 2 * p2[1] + t ** 3 * p3[1]
smoothed_points.extend([x, y])
return smoothed_points
def clear_canvas(self):
self.canvas.delete("all") # 清空画布
self.result_label.config(text="画一个圆,松开鼠标查看结果") # 重置结果标签
self.points = [] # 清空点列表
self.drawing = False # 重置绘制状态
self.line_id = None # 重置线条ID
# 生成随机颜色
def random_color(self):
return "#{:06x}".format(random.randint(0, 0xFFFFFF))
# 炫彩变色
def change_color(self):
self.start_label.config(fg=self.random_color())
self.root.after(500, self.change_color) # 每隔500毫秒更新标题标签的颜色
# 排行榜
def show_leaderboard(self):
leaderboard_window = tk.Toplevel(self.root)
leaderboard_window.title("排行榜")
# 创建一个表格来显示成绩
columns = ("排名", "时间", "估算π值", "误差", "评级")
leaderboard_tree = ttk.Treeview(leaderboard_window, columns=columns, show="headings")
leaderboard_tree.pack()
# 设置列的标题
for col in columns:
leaderboard_tree.heading(col, text=col)
# 设置列的宽度和对齐方式
leaderboard_tree.column("排名", width=50, anchor="center")
leaderboard_tree.column("时间", width=150, anchor="center")
leaderboard_tree.column("估算π值", width=100, anchor="center")
leaderboard_tree.column("误差", width=100, anchor="center")
leaderboard_tree.column("评级", width=50, anchor="center")
# 从数据库读取成绩
self.cursor.execute('SELECT time, pi_estimate, error_percent, grade FROM scores ORDER BY error_percent ASC')
scores = self.cursor.fetchall()
# 将成绩插入到排行榜表格中
for index, (time, pi, error, grade) in enumerate(scores):
leaderboard_tree.insert("", "end", values=(index + 1, time, f"{pi:.4f}", f"{error:.2f}", grade))
def init_db(self):
# 连接到SQLite数据库,如果数据库不存在,会自动创建一个新的数据库文件
self.conn = sqlite3.connect('scores.db')
self.cursor = self.conn.cursor()
# 创建成绩表
self.cursor.execute('''
CREATE TABLE IF NOT EXISTS scores (
id INTEGER PRIMARY KEY AUTOINCREMENT,
time TEXT NOT NULL,
pi_estimate REAL NOT NULL,
error_percent REAL NOT NULL,
grade TEXT NOT NULL
)
''')
self.conn.commit() # 提交更改
def save_score(self, time, pi_estimate, error_percent, grade):
self.cursor.execute('''
INSERT INTO scores (time, pi_estimate, error_percent, grade) VALUES (?, ?, ?, ?)
''', (time, pi_estimate, error_percent, grade))
self.conn.commit()
# 关闭数据库连接
def __del__(self):
self.conn.close()
root = tk.Tk()
app = CircleDrawingApp(root)
root.mainloop()
五、演示视频
六、使用说明
需要下载:
1.numpy
pip install numpy
- 或清华镜像
pip install -i https://pypi.tuna.tsinghua.edu.cn/simple numpy
2.scipy
pip install scipy
- 或清华镜像
pip install -i https://pypi.tuna.tsinghua.edu.cn/simple scipy
七、遇到的问题及优化
(一)每次画圆切换随机色彩
-
问题:多次画圆会重叠,区分度不高,每次清空又有点麻烦
-
优化:加入随机色彩切换,减少“清空”使用频率。
def random_color(self):
return "#{:06x}".format(random.randint(0, 0xFFFFFF))
(二)加入对是否是圆的判断
-
问题:用户可能瞎画
-
优化:通过检查拟合圆的周长是否超过绘制轨迹长度的1.5倍等方法,排除用户瞎画或误触,增强程序健壮性。
if circumference * 1.5 < 2 * math.pi * radius:
self.result_label.config(text="请确认您绘制的是否是圆")
return
(三)改进判断圆的算法
-
使用圆的参数方程来计算圆的周长,而不是使用所有点的距离之和,能够更有效精确地进行判断。
(四)视觉优化
-
加入炫彩变色,改进开始界面GUI布局,使用微软雅黑字体。
-
问题:试图将排行榜按钮的pack方法改为grid方法,以便更精确地控制按钮的位置,但是出现大量问题
-
解决:问题出在尝试使用grid布局管理器时,game_frame已经使用了pack布局管理器。在Tkinter中,一个容器(如Frame)不能同时使用两种不同的布局管理器(pack、grid或place)来管理其子组件
(五)加入历史记录“排行榜”功能
-
问题:无法比较每次的成绩,就算加入简单的“排行榜”,也只能记录每次运行程序时画的圆。
-
优化:加入数据库,能够在关闭后程序后储存历史记录
def show_leaderboard(self):
leaderboard_window = tk.Toplevel(self.root)
leaderboard_window.title("排行榜")
…………
self.cursor.execute('SELECT time, pi_estimate, error_percent, grade FROM scores ORDER BY error_percent ASC')
scores = self.cursor.fetchall()
(六)优化用户手绘线条视觉效果
-
问题:用户手绘线条边缘锯齿明显,非常影响观感。
-
优化:使用贝塞尔曲线平滑算法使绘制的曲线平滑。
def get_smoothed_points(self):
if len(self.points) < 4:
return [p for point in self.points for p in point]
smoothed_points = []
for i in range(1, len(self.points) - 2):
p0, p1, p2, p3 = self.points[i - 1], self.points[i], self.points[i + 1], self.points[i + 2]
t_values = np.linspace(0, 1, 20)
for t in t_values:
x = (1 - t) ** 3 * p0[0] + 3 * (1 - t) ** 2 * t * p1[0] + 3 * (1 - t) * t ** 2 * p2[0] + t ** 3 * p3[0]
y = (1 - t) ** 3 * p0[1] + 3 * (1 - t) ** 2 * t * p1[1] + 3 * (1 - t) * t ** 2 * p2[1] + t ** 3 * p3[1]
smoothed_points.extend([x, y])
return smoothed_points
(七)优化评级
-
问题:早期评级打分机制不合理,用户几乎只能拿到“C”“D”“E”
-
优化:邀请他人(室友、同学)进行反复测试,寻找算法打分与人类鼠绘水平的平衡。
八、收获与思考
-
1.学习了Tkinter这一Python的标准GUI库的一些基础用法,可以制作简单的图形用户界面.
-
2.通过对更好的数学算法(SciPy中的least_squares函数、平滑曲线函数等)的寻找,练习了怎样去查找并使用一些现成的库,怎样应用一些数学算法对图形坐标进行处理。
-
3.强化练习了Python课学到的诸如类、数据库等新知识。