20242215 2024-2025-2 《Python程序设计》实验四报告

课程:《Python程序设计》
班级: 2422
姓名: 贾瑞宁
学号:20242215
实验教师:王志强
实验日期:2025年5月14日
必修/选修: 公选课

一、设计思路概述

  • 本游戏的灵感来源于今年3月14日(国际圆周率日,国际数学日)见到的一个庆祝用的网页小游戏。

  • 程序的主要设计思路是:记录用户手绘圆的轨迹,通过数学方法拟合一个标准的圆(计算残差并调用非线性最小二乘函数),计算出这个拟合圆的周长,与用户手绘圆的周长进行比较,进而对用户手绘圆的标准性做出判断(评级),并在排行榜中记录各次的评级。

二、文献研究和调研

(一)Thinker库

(二)Numpy包

  • CSDN博客:《Python之Numpy详细教程》

  • NumPy是一个Python包。它代表“Numeric Python”。它是一个由多维数组对象和用于处理数组的例程集合组成的库。

  • 使用NumPy,开发人员可以执行以下操作:

  • 数组的算数和逻辑运算;
  • 傅立叶变换和用于图形操作的例程;
  • 与线性代数有关的操作(NumPy拥有线性代数和随机数生成的内置函数)。

(三)最小二乘函数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课学到的诸如类、数据库等新知识。

九、《Python程序设计》课程总结收获

在《Python程序设计》这门课程的学习中,王志强老师为我们提供了Python的框架和引导,需要结合老师推荐的《零基础学Python》这本书进行学习,更进一步必须要学会在互联网上(AI、论坛、博客、百科……)查找资料进行学习。

课程早期,我们学习的内容一部分比较接近大一《程序设计基础》的内容,相当于是一种切换的过程(虽然只刚学了一点C语言,但是诸如大括号、分号、缩进、注释等还是形成了一定习惯,需要熟悉新的语法);另一部分就是对于Python的独特性的了解,是一个熟悉了解Python的过程(当然,也熟悉了许多计算机领域的基础概念),我们学习了诸如“面向对象”这种以前没有具体了解过的概念。

课程中后期,随着学习逐渐深入,我们学习了“序列”“正则表达式”“类”“数据库”等,这些内容很依赖自学,很惭愧除了听课之外我没有非常深入地学习,只有在设计程序时,伴随着使用去尝试接触了一些,同样一知半解。希望未来三年的大学学习中,我有机会进一步在实践中学习、应用。

posted @ 2025-06-08 14:05  熵非时  阅读(33)  评论(0)    收藏  举报