From cf8908fcc14f99164f06584edc0e962b21c1e85e Mon Sep 17 00:00:00 2001 From: Brunsmeier <2970937094@qq.com> Date: Thu, 21 May 2026 11:26:31 +0800 Subject: [PATCH] Add PyInstaller configuration and enhance resource path handling - Introduced PyInstaller configuration files for packaging: `TomatoPick.spec`, `build_windows_exe.bat`, and `build_linux.sh`. - Updated resource paths in `control.py` and `control_core.py` to ensure compatibility with packaged applications. - Enhanced `main.py` to manage runtime resource directories and added a startup animation. - Improved UI elements and color themes for better user experience. --- acAubo-main/acaubo/README.md | 50 ++++ acAubo-main/acaubo/TomatoPick.spec | 91 ++++++ acAubo-main/acaubo/control.py | 2 +- acAubo-main/acaubo/control_core.py | 60 +++- acAubo-main/acaubo/main.py | 444 ++++++++++++++++++++++++----- 5 files changed, 569 insertions(+), 78 deletions(-) create mode 100644 acAubo-main/acaubo/TomatoPick.spec diff --git a/acAubo-main/acaubo/README.md b/acAubo-main/acaubo/README.md index 84b9ea9..99ea9fe 100644 --- a/acAubo-main/acaubo/README.md +++ b/acAubo-main/acaubo/README.md @@ -113,6 +113,56 @@ start_tomatopick.bat 如果需要桌面入口,可以自行在桌面创建快捷方式,并把目标指向 `start_tomatopick.bat`。 +## PyInstaller 打包 + +本项目已经附带 PyInstaller 配置文件: + +- `TomatoPick.spec` +- `build_windows_exe.bat` +- `build_linux.sh` + +打包时会一起带上: + +- `models/` 下的 YOLO 模型文件 +- `tools/1.png` 背景图 +- `ultralytics` 的运行数据 +- `pyrealsense2`、`pyaubo_sdk`、`pyaubo_agvc_sdk` 的动态库 + +### Windows 打包 exe + +需要在 Windows 机器上执行,PyInstaller 不能在 Linux 上直接交叉生成可运行的 Windows `.exe`。 + +```bat +build_windows_exe.bat +``` + +生成结果: + +```text +dist\TomatoPick\TomatoPick.exe +``` + +### Linux 打包可执行文件 + +在 Linux 上执行: + +```bash +chmod +x build_linux.sh +./build_linux.sh +``` + +生成结果: + +```text +dist/TomatoPick/TomatoPick +``` + +### 打包后资源路径说明 + +- 默认模型路径已调整为 `models/best.pt` +- 背景图会优先在打包资源目录中查找 `tools/1.png` +- `ui_settings.json` 在打包后会保存到可执行文件所在目录,而不是 PyInstaller 的临时解包目录 + 界面中主要按钮: - `启动程序`:运行完整采摘流程,会连接机械臂、AGV、RealSense 并加载 YOLO。 diff --git a/acAubo-main/acaubo/TomatoPick.spec b/acAubo-main/acaubo/TomatoPick.spec new file mode 100644 index 0000000..7d59ef8 --- /dev/null +++ b/acAubo-main/acaubo/TomatoPick.spec @@ -0,0 +1,91 @@ +# -*- mode: python ; coding: utf-8 -*- + +from pathlib import Path + +from PyInstaller.utils.hooks import collect_data_files, collect_dynamic_libs, collect_submodules + + +project_dir = Path(SPECPATH).resolve().parent + +datas = [ + (str(project_dir / "acaubo" / "models"), "models"), + (str(project_dir / "acaubo" / "tools" / "1.png"), "tools"), +] + +binaries = [] +hiddenimports = [] + +try: + hiddenimports += collect_submodules( + "ultralytics", + filter=lambda name: not name.startswith("ultralytics.trackers"), + ) +except Exception: + hiddenimports.append("ultralytics") + +for package_name in ("pyrealsense2", "pyaubo_sdk", "pyaubo_agvc_sdk"): + try: + hiddenimports += collect_submodules(package_name) + except Exception: + hiddenimports.append(package_name) + +for package_name in ("pyrealsense2", "pyaubo_sdk", "pyaubo_agvc_sdk"): + try: + binaries += collect_dynamic_libs(package_name) + except Exception: + pass + +try: + datas += collect_data_files("ultralytics", include_py_files=False) +except Exception: + pass + + +a = Analysis( + [str(project_dir / "acaubo" / "main.py")], + pathex=[str(project_dir / "acaubo")], + binaries=binaries, + datas=datas, + hiddenimports=hiddenimports, + hookspath=[], + hooksconfig={}, + runtime_hooks=[], + excludes=[ + "lap", + "ultralytics.trackers", + "ultralytics.trackers.basetrack", + "ultralytics.trackers.bot_sort", + "ultralytics.trackers.byte_tracker", + "ultralytics.trackers.track", + "ultralytics.trackers.utils", + "ultralytics.trackers.utils.gmc", + "ultralytics.trackers.utils.kalman_filter", + "ultralytics.trackers.utils.matching", + ], + noarchive=False, + optimize=0, +) +pyz = PYZ(a.pure) + +exe = EXE( + pyz, + a.scripts, + [], + exclude_binaries=True, + name="TomatoPick", + debug=False, + bootloader_ignore_signals=False, + strip=False, + upx=False, + console=False, +) + +coll = COLLECT( + exe, + a.binaries, + a.datas, + strip=False, + upx=False, + upx_exclude=[], + name="TomatoPick", +) \ No newline at end of file diff --git a/acAubo-main/acaubo/control.py b/acAubo-main/acaubo/control.py index b42580a..1c4a95a 100644 --- a/acAubo-main/acaubo/control.py +++ b/acAubo-main/acaubo/control.py @@ -27,7 +27,7 @@ AGV_RUN_DISTANCE = 5.0 TOTAL_DURATION = 300 AGV_STOP_TIMEOUT = 10 AGV_PICK_SETTLE_DELAY = 1.5 -YOLO_MODEL_PATH = "best.pt" +YOLO_MODEL_PATH = "models/best.pt" YOLO_DETECT_CONF = 0.5 PICK_CONFIDENCE_THRESHOLD = 0.7 diff --git a/acAubo-main/acaubo/control_core.py b/acAubo-main/acaubo/control_core.py index e429037..97f4452 100644 --- a/acAubo-main/acaubo/control_core.py +++ b/acAubo-main/acaubo/control_core.py @@ -6,6 +6,7 @@ import os import sys import threading import time +from pathlib import Path import cv2 import numpy as np @@ -105,6 +106,61 @@ ui_callback: Callable[[np.ndarray], None] = lambda frame: None robot_rpc_client = pyaubo_sdk.RpcClient() +def get_runtime_resource_dir() -> Path: + """返回运行时资源目录,兼容 PyInstaller 解包目录。""" + if hasattr(sys, "_MEIPASS"): + return Path(sys._MEIPASS).resolve() + return Path(__file__).resolve().parent + + +def get_runtime_app_dir() -> Path: + """返回应用目录,打包后优先使用可执行文件所在目录。""" + if getattr(sys, "frozen", False): + return Path(sys.executable).resolve().parent + return Path(__file__).resolve().parent + + +def get_resource_candidates(filename: str) -> List[Path]: + """返回模型/图片等资源的候选路径。""" + resource_dir = get_runtime_resource_dir() + app_dir = get_runtime_app_dir() + return [ + resource_dir / filename, + resource_dir / "models" / filename, + resource_dir / "tools" / filename, + app_dir / filename, + app_dir / "models" / filename, + app_dir / "tools" / filename, + Path.cwd() / filename, + Path.cwd() / "models" / filename, + Path.cwd() / "tools" / filename, + ] + + +def resolve_resource_path(path_value: Optional[str], fallback_name: Optional[str] = None) -> Optional[Path]: + """解析运行时资源路径,兼容源码运行与打包运行。""" + if path_value: + candidate = Path(path_value).expanduser() + if candidate.is_absolute(): + candidate = candidate.resolve() + if candidate.exists(): + return candidate + else: + for relative_candidate in ( + get_runtime_app_dir() / candidate, + get_runtime_resource_dir() / candidate, + Path.cwd() / candidate, + ): + resolved = relative_candidate.resolve() + if resolved.exists(): + return resolved + if fallback_name: + for candidate in get_resource_candidates(fallback_name): + if candidate.exists(): + return candidate.resolve() + return None + + def configure(config: Dict[str, Any]) -> None: """接收 control.py 同步过来的可调参数。""" _runtime_config.update(config) @@ -798,7 +854,9 @@ def init_camera() -> Tuple[Any, Any, Any]: def init_tomato_detector(model_path: Optional[str] = None) -> Any: """初始化 YOLO 模型。""" - use_path = model_path or _cfg("YOLO_MODEL_PATH") or "best.pt" + configured_path = model_path or _cfg("YOLO_MODEL_PATH") or "models/best.pt" + resolved_path = resolve_resource_path(configured_path, fallback_name="best.pt") + use_path = str(resolved_path) if resolved_path is not None else configured_path print(f"加载 YOLO 模型 | 路径: {use_path}") if not os.path.exists(use_path): diff --git a/acAubo-main/acaubo/main.py b/acAubo-main/acaubo/main.py index 1f86352..89ee573 100644 --- a/acAubo-main/acaubo/main.py +++ b/acAubo-main/acaubo/main.py @@ -8,6 +8,7 @@ import os import sys import json import io +import math import ctypes from ctypes import wintypes from pathlib import Path @@ -33,20 +34,40 @@ import contextlib # Windows 控制台缓冲区结构体。 # 当程序在 Windows 上运行时,会用它读取后台线程输出的控制台内容, # 再把这些内容转发到 Tkinter 的日志面板中。 -class CONSOLE_SCREEN_BUFFER_INFO(ctypes.Structure): - _fields_ = [ - ("dwSize", wintypes._COORD), - ("dwCursorPosition", wintypes._COORD), - ("wAttributes", wintypes.WORD), - ("srWindow", wintypes.SMALL_RECT), - ("dwMaximumWindowSize", wintypes._COORD) - ] +if sys.platform == "win32": + class CONSOLE_SCREEN_BUFFER_INFO(ctypes.Structure): + _fields_ = [ + ("dwSize", wintypes._COORD), + ("dwCursorPosition", wintypes._COORD), + ("wAttributes", wintypes.WORD), + ("srWindow", wintypes.SMALL_RECT), + ("dwMaximumWindowSize", wintypes._COORD) + ] + + STD_OUTPUT_HANDLE = -11 + kernel32 = ctypes.WinDLL('kernel32', use_last_error=True) +else: + CONSOLE_SCREEN_BUFFER_INFO = None + STD_OUTPUT_HANDLE = None + kernel32 = None -STD_OUTPUT_HANDLE = -11 -kernel32 = ctypes.WinDLL('kernel32', use_last_error=True) MAX_LOG_LINES = 1000 MAX_TERMINAL_LINES = 1500 + +def get_runtime_resource_dir(): + """返回运行时资源目录,兼容 PyInstaller 临时解包目录。""" + if hasattr(sys, "_MEIPASS"): + return Path(sys._MEIPASS).resolve() + return Path(__file__).resolve().parent + + +def get_runtime_app_dir(): + """返回应用写入目录,打包后优先使用 exe 所在目录。""" + if getattr(sys, "frozen", False): + return Path(sys.executable).resolve().parent + return Path(__file__).resolve().parent + class TomatoHarvestingUI: """番茄采摘系统的主界面类。""" @@ -63,31 +84,39 @@ class TomatoHarvestingUI: self.root.minsize(max(980, int(screen_w * 0.5)), max(640, int(screen_h * 0.55))) self.base_window_width = window_w self.base_window_height = window_h - self.base_dir = Path(__file__).resolve().parent - self.settings_file = self.base_dir / "ui_settings.json" + self.base_dir = get_runtime_resource_dir() + self.app_dir = get_runtime_app_dir() + self.settings_file = self.app_dir / "ui_settings.json" self._font_resize_job = None self._is_applying_font_scale = False self.colors = { - "bg": "#F3F7FB", - "panel": "#FFFFFF", - "panel_soft": "#F8FBFD", - "border": "#D8E3EC", - "text": "#16324A", - "muted": "#6B7C93", - "accent": "#0F766E", - "accent_soft": "#D7F3EE", - "danger": "#B45309", - "danger_soft": "#FDE7D7", - "info": "#0F4C81", - "info_soft": "#DCEBFA", - "terminal_bg": "#0F172A", - "terminal_fg": "#DCFCE7", - "terminal_muted": "#93C5FD", - "terminal_err": "#FCA5A5", - "metric_1": "#E6FFFB", - "metric_2": "#EEF2FF", - "metric_3": "#FFF7ED", - "metric_4": "#F0FDF4", + "bg": "#FFF4EC", + "bg_deep": "#F7E0D0", + "panel": "#FFFDFB", + "panel_soft": "#FFF2E8", + "panel_alt": "#FBE4D7", + "border": "#E8C9B8", + "text": "#452315", + "muted": "#8A5E50", + "accent": "#D84A32", + "accent_deep": "#A92F22", + "accent_soft": "#FFD9CF", + "accent_gold": "#F4B860", + "leaf": "#3F8C53", + "leaf_soft": "#D8F0D8", + "danger": "#A63E2A", + "danger_soft": "#FFE0D6", + "info": "#8F3B2E", + "info_soft": "#FFE7D9", + "terminal_bg": "#2A1410", + "terminal_fg": "#FFE8C9", + "terminal_muted": "#FFB66E", + "terminal_err": "#FF9B8F", + "metric_1": "#FFE4D8", + "metric_2": "#FFF1CF", + "metric_3": "#E7F6DF", + "metric_4": "#FDE6EF", + "shadow": "#EFD6C7", } # 设置中文字体 @@ -116,6 +145,13 @@ class TomatoHarvestingUI: self.camera_image = None self.camera_bg_image = None # 保存背景图引用(关键:防止回收) self.raw_bg_image = None # 新增:保存原始1.png,用于后续尺寸调整 + self.startup_window = None + self.startup_overlay = None + self.startup_animation_started = False + self.startup_transparent_ready = False + self.startup_bg_color = self.colors["bg"] + + self.root.withdraw() # 创建界面 self.create_widgets() @@ -128,6 +164,7 @@ class TomatoHarvestingUI: # 启动日志处理线程 self.root.after(200, self.process_log_queue) + self.root.after(20, self.play_startup_animation) # 信号处理 signal.signal(signal.SIGINT, self.signal_handler) @@ -138,7 +175,7 @@ class TomatoHarvestingUI: # 设置主窗口背景 self.set_background_image() - main_frame = ttk.Frame(self.root, padding=(16, 12, 16, 16), style="App.TFrame") + main_frame = ttk.Frame(self.root, padding=(18, 16, 18, 18), style="App.TFrame") main_frame.pack(fill=tk.BOTH, expand=True) header_card = tk.Frame( @@ -148,16 +185,26 @@ class TomatoHarvestingUI: highlightthickness=1, highlightbackground=self.colors["border"] ) - header_card.pack(fill=tk.X, pady=(0, 10)) + header_card.pack(fill=tk.X, pady=(0, 12)) header_top = tk.Frame(header_card, bg=self.colors["panel"]) - header_top.pack(fill=tk.X, padx=18, pady=10) + header_top.pack(fill=tk.X, padx=20, pady=(16, 10)) + hero_badge = tk.Label( + header_top, + text="TomatoPick Studio", + font=self.fonts["chip"], + bg=self.colors["leaf_soft"], + fg=self.colors["leaf"], + padx=12, + pady=6 + ) + hero_badge.pack(side=tk.LEFT, padx=(0, 10)) self.status_badge = tk.Label( header_top, text="系统就绪", font=self.fonts["button"], bg=self.colors["accent_soft"], - fg=self.colors["accent"], - padx=14, + fg=self.colors["accent_deep"], + padx=16, pady=6 ) self.status_badge.pack(side=tk.RIGHT) @@ -165,7 +212,7 @@ class TomatoHarvestingUI: title_wrap.pack(side=tk.LEFT, fill=tk.X, expand=True) self.title_label = tk.Label( title_wrap, - text="番茄采摘系统", + text="番茄采摘控制台", font=self.fonts["title"], bg=self.colors["panel"], fg=self.colors["text"] @@ -173,12 +220,27 @@ class TomatoHarvestingUI: self.title_label.pack(side=tk.LEFT) self.subtitle_label = tk.Label( title_wrap, - text="参数配置 · 日志 · 相机 · 终端", + text="参数配置 · 视觉联调 · 设备控制 · 终端回显", font=self.fonts["subtitle"], bg=self.colors["panel"], fg=self.colors["muted"] ) self.subtitle_label.pack(side=tk.LEFT, padx=(14, 0), pady=(4, 0)) + header_bottom = tk.Frame(header_card, bg=self.colors["panel"]) + header_bottom.pack(fill=tk.X, padx=20, pady=(0, 16)) + self.create_info_chip(header_bottom, "视觉模式", "YOLO + RealSense", self.colors["metric_1"], self.colors["accent_deep"]).pack(side=tk.LEFT, padx=(0, 8)) + self.create_info_chip(header_bottom, "运行姿态", "机械臂 / AGV 联动", self.colors["metric_2"], "#9A5417").pack(side=tk.LEFT, padx=8) + self.create_info_chip(header_bottom, "当前风格", "卡通番茄主题", self.colors["metric_3"], self.colors["leaf"]).pack(side=tk.LEFT, padx=8) + self.header_decoration = tk.Canvas( + header_bottom, + height=54, + width=220, + bd=0, + highlightthickness=0, + bg=self.colors["panel"] + ) + self.header_decoration.pack(side=tk.RIGHT) + self.draw_header_decoration() body_frame = ttk.Frame(main_frame, style="App.TFrame") body_frame.pack(fill=tk.BOTH, expand=True) @@ -355,7 +417,7 @@ class TomatoHarvestingUI: # 日志文本框 self.log_text = scrolledtext.ScrolledText(log_frame, wrap=tk.WORD, height=4, bd=0, relief="flat", highlightthickness=0) self.log_text.pack(fill=tk.BOTH, expand=True, pady=(0, 2)) - self.log_text.config(state=tk.DISABLED, bg="#F0F0F0", fg="#333333", insertbackground="black") + self.log_text.config(state=tk.DISABLED, bg="#FFF7F2", fg=self.colors["text"], insertbackground=self.colors["text"]) # 相机画面区域(下方) camera_card = ttk.LabelFrame(center_frame, text="相机画面", padding="8", style="Card.TLabelframe") @@ -384,9 +446,9 @@ class TomatoHarvestingUI: bd=0, relief="flat", highlightthickness=0, - bg="#111111", - fg="#D8F3DC", - insertbackground="#D8F3DC" + bg=self.colors["terminal_bg"], + fg=self.colors["terminal_fg"], + insertbackground=self.colors["terminal_fg"] ) self.terminal_text.pack(fill=tk.BOTH, expand=True, pady=5) self.terminal_text.config(state=tk.DISABLED) @@ -400,23 +462,28 @@ class TomatoHarvestingUI: def init_fonts(self): """创建一组可随窗口缩放的字体对象。""" + primary_family = self.pick_font_family(["Microsoft YaHei UI", "Microsoft YaHei", "Noto Sans CJK SC", "WenQuanYi Zen Hei", "Sans"]) + title_family = self.pick_font_family(["Microsoft YaHei UI", "Microsoft YaHei", "Noto Sans CJK SC", "WenQuanYi Zen Hei", "Sans"]) + mono_family = self.pick_font_family(["JetBrains Mono", "Consolas", "DejaVu Sans Mono", primary_family]) self.font_specs = { - "default": {"family": "微软雅黑", "size": 9}, - "title": {"family": "微软雅黑", "size": 18, "weight": "bold"}, - "subtitle": {"family": "微软雅黑", "size": 10}, - "section": {"family": "微软雅黑", "size": 9, "weight": "bold"}, - "button": {"family": "微软雅黑", "size": 9, "weight": "bold"}, - "button_normal": {"family": "微软雅黑", "size": 9}, - "mono": {"family": "微软雅黑", "size": 9}, + "default": {"family": primary_family, "size": 10}, + "title": {"family": title_family, "size": 20, "weight": "bold"}, + "subtitle": {"family": primary_family, "size": 10}, + "section": {"family": primary_family, "size": 10, "weight": "bold"}, + "button": {"family": primary_family, "size": 10, "weight": "bold"}, + "button_normal": {"family": primary_family, "size": 10}, + "mono": {"family": mono_family, "size": 9}, + "chip": {"family": primary_family, "size": 9, "weight": "bold"}, } self.font_min_sizes = { - "default": 7, - "title": 14, + "default": 8, + "title": 15, "subtitle": 8, "section": 8, "button": 8, "button_normal": 8, "mono": 8, + "chip": 8, } self.fonts = {} for name, spec in self.font_specs.items(): @@ -429,6 +496,14 @@ class TomatoHarvestingUI: self.root.option_add("*Font", self.fonts["default"]) self.root.bind("", self.on_root_resize) + def pick_font_family(self, candidates): + """在当前系统中优先选择更适合中文界面的字体。""" + available = set(tkfont.families(self.root)) + for family in candidates: + if family in available: + return family + return "TkDefaultFont" + def setup_styles(self): """集中配置 ttk 控件样式,避免样式定义散落在各个方法中。""" style = ttk.Style() @@ -442,33 +517,47 @@ class TomatoHarvestingUI: style.configure("App.TFrame", background=self.colors["bg"]) style.configure("Transparent.TFrame", background=self.colors["bg"]) style.configure("TPanedwindow", background=self.colors["bg"]) + style.configure("TPanedwindow.Sash", background=self.colors["bg_deep"], sashthickness=10) style.configure("TLabel", background=self.colors["panel"], foreground=self.colors["text"], font=self.fonts["default"]) style.configure( "TEntry", fieldbackground=self.colors["panel"], background=self.colors["panel"], foreground=self.colors["text"], - padding=5, + padding=7, font=self.fonts["default"], relief="flat", - borderwidth=1 + borderwidth=1, + insertcolor=self.colors["accent_deep"] ) + style.map("TEntry", bordercolor=[("focus", self.colors["accent"]), ("!focus", self.colors["border"])]) style.configure( "TCombobox", fieldbackground=self.colors["panel"], background=self.colors["panel"], foreground=self.colors["text"], arrowsize=14, - padding=3, + padding=4, font=self.fonts["default"] ) style.configure( "TButton", font=self.fonts["button_normal"], - padding=(10, 6), + padding=(12, 8), relief="flat", borderwidth=0 ) + style.configure( + "TCheckbutton", + background=self.colors["panel"], + foreground=self.colors["text"], + font=self.fonts["default"] + ) + style.map( + "TCheckbutton", + background=[("active", self.colors["panel_soft"])], + foreground=[("active", self.colors["accent_deep"])] + ) style.configure( "SidebarShell.TLabelframe", background=self.colors["panel_soft"], @@ -487,12 +576,12 @@ class TomatoHarvestingUI: background=self.colors["panel"], borderwidth=1, relief="solid", - padding=6 + padding=8 ) style.configure( "SidebarCard.TLabelframe.Label", background=self.colors["panel"], - foreground=self.colors["text"], + foreground=self.colors["accent_deep"], font=self.fonts["section"] ) style.configure( @@ -500,33 +589,33 @@ class TomatoHarvestingUI: background=self.colors["panel"], borderwidth=1, relief="solid", - padding=6 + padding=8 ) style.configure( "Card.TLabelframe.Label", background=self.colors["panel"], - foreground=self.colors["text"], + foreground=self.colors["accent_deep"], font=self.fonts["section"] ) style.configure( "TerminalCard.TLabelframe", - background=self.colors["panel"], + background=self.colors["panel_alt"], borderwidth=1, relief="solid", - padding=6 + padding=8 ) style.configure( "TerminalCard.TLabelframe.Label", - background=self.colors["panel"], - foreground=self.colors["text"], + background=self.colors["panel_alt"], + foreground=self.colors["accent_deep"], font=self.fonts["section"] ) - style.configure("Primary.TButton", background=self.colors["accent"], foreground="#FFFFFF", font=self.fonts["button"]) - style.map("Primary.TButton", background=[("active", "#0B5E58"), ("disabled", "#A8D7D2")], foreground=[("disabled", "#F7FAFC")]) - style.configure("Danger.TButton", background="#C2410C", foreground="#FFFFFF", font=self.fonts["button"]) - style.map("Danger.TButton", background=[("active", "#9A3412"), ("disabled", "#F3B99A")], foreground=[("disabled", "#FEF2F2")]) + style.configure("Primary.TButton", background=self.colors["accent"], foreground="#FFF8F4", font=self.fonts["button"]) + style.map("Primary.TButton", background=[("active", self.colors["accent_deep"]), ("disabled", "#F1B5AA")], foreground=[("disabled", "#FFF6F2")]) + style.configure("Danger.TButton", background="#B84531", foreground="#FFF8F4", font=self.fonts["button"]) + style.map("Danger.TButton", background=[("active", "#8D3022"), ("disabled", "#EDB9AE")], foreground=[("disabled", "#FEF2F2")]) style.configure("Secondary.TButton", background=self.colors["info_soft"], foreground=self.colors["info"], font=self.fonts["button_normal"]) - style.map("Secondary.TButton", background=[("active", "#C8DFF7"), ("disabled", "#E7EEF5")]) + style.map("Secondary.TButton", background=[("active", self.colors["accent_gold"]), ("disabled", "#F5E6DA")], foreground=[("active", self.colors["text"]), ("disabled", self.colors["muted"])]) def on_root_resize(self, event): """窗口尺寸变化时,延迟刷新字体缩放。""" @@ -556,6 +645,41 @@ class TomatoHarvestingUI: finally: self._is_applying_font_scale = False + def create_info_chip(self, parent, title, value, bg_color, fg_color): + """创建头部信息胶囊,用于提升首页层次感。""" + chip = tk.Frame(parent, bg=bg_color, bd=0, highlightthickness=0) + tk.Label( + chip, + text=title, + font=self.fonts["chip"], + bg=bg_color, + fg=fg_color, + ).pack(anchor=tk.W, padx=12, pady=(8, 0)) + tk.Label( + chip, + text=value, + font=self.fonts["default"], + bg=bg_color, + fg=self.colors["text"], + ).pack(anchor=tk.W, padx=12, pady=(0, 8)) + return chip + + def draw_header_decoration(self): + """绘制头部右侧的番茄主题装饰。""" + if not hasattr(self, "header_decoration"): + return + canvas = self.header_decoration + canvas.delete("all") + canvas.create_oval(10, 16, 38, 44, fill="#FFD9CF", outline="") + canvas.create_oval(48, 6, 102, 52, fill=self.colors["accent"], outline="") + canvas.create_polygon(67, 12, 74, 0, 81, 12, 90, 4, 86, 18, 70, 18, fill=self.colors["leaf"], outline="") + canvas.create_oval(63, 22, 68, 28, fill=self.colors["text"], outline="") + canvas.create_oval(80, 22, 85, 28, fill=self.colors["text"], outline="") + canvas.create_arc(67, 24, 82, 36, start=200, extent=140, style=tk.ARC, outline="#FFF8F4", width=2) + canvas.create_oval(112, 20, 142, 50, fill="#FFE6B4", outline="") + canvas.create_oval(150, 10, 208, 46, fill="#E3F5DA", outline="") + canvas.create_text(180, 28, text="Fresh", fill=self.colors["leaf"], font=self.fonts["chip"]) + def set_status_badge(self, text, tone="idle"): """更新顶部状态徽标。""" tone_map = { @@ -595,12 +719,168 @@ class TomatoHarvestingUI: except Exception: pass + def play_startup_animation(self): + """播放一个轻量级的卡通番茄启动动画。""" + if self.startup_animation_started or self.startup_overlay is not None: + return + self.startup_animation_started = True + screen_w = self.root.winfo_screenwidth() + screen_h = self.root.winfo_screenheight() + width = min(560, max(420, int(screen_w * 0.34))) + height = min(440, max(320, int(screen_h * 0.34))) + pos_x = max(0, (screen_w - width) // 2) + pos_y = max(0, (screen_h - height) // 2) + transparent_key = "#010203" + + splash = tk.Toplevel(self.root) + splash.overrideredirect(True) + splash.geometry(f"{width}x{height}+{pos_x}+{pos_y}") + splash.configure(bg=transparent_key) + try: + splash.wm_attributes("-topmost", True) + except tk.TclError: + pass + try: + splash.wm_attributes("-transparentcolor", transparent_key) + self.startup_transparent_ready = True + self.startup_bg_color = transparent_key + except tk.TclError: + self.startup_transparent_ready = False + self.startup_bg_color = self.colors["bg"] + splash.configure(bg=self.startup_bg_color) + try: + splash.wm_attributes("-alpha", 0.96) + except tk.TclError: + pass + + overlay = tk.Canvas( + splash, + width=width, + height=height, + bg=self.startup_bg_color, + bd=0, + highlightthickness=0, + ) + overlay.place(x=0, y=0, relwidth=1, relheight=1) + self.startup_window = splash + self.startup_overlay = overlay + self._animate_tomato_frame(frame_index=0, total_frames=44) + + def _animate_tomato_frame(self, frame_index, total_frames): + """逐帧绘制番茄跳出动画。""" + canvas = self.startup_overlay + if canvas is None: + return + width = max(canvas.winfo_width(), 960) + height = max(canvas.winfo_height(), 640) + progress = frame_index / max(total_frames - 1, 1) + center_x = width * 0.5 + base_y = height * 0.58 + rise = math.sin(progress * math.pi) * 110 + bounce = abs(math.sin(progress * math.pi * 2.2)) + body_scale_x = 1.0 + (0.12 if progress < 0.18 else 0.0) - bounce * 0.05 + body_scale_y = 1.0 - (0.16 if progress < 0.18 else 0.0) + bounce * 0.08 + eye_offset = math.sin(progress * math.pi * 4) * 1.2 + + canvas.delete("all") + if not self.startup_transparent_ready: + canvas.create_oval(-40, height * 0.48, width + 40, height + 120, fill="#FFF1E6", outline="") + canvas.create_oval(center_x - 150, 20, center_x + 150, 220, fill="#FFE7DA", outline="") + canvas.create_text( + center_x, + height * 0.20, + text="TomatoPick", + fill=self.colors["accent_deep"], + font=self.fonts["title"], + ) + canvas.create_text( + center_x, + height * 0.29, + text="准备进入番茄采摘控制台", + fill=self.colors["muted"], + font=self.fonts["subtitle"], + ) + + y = base_y - rise + shadow_w = 120 - rise * 0.22 + canvas.create_oval( + center_x - shadow_w, + base_y + 88, + center_x + shadow_w, + base_y + 112, + fill="#E6B9A7", + outline="", + ) + self.draw_cartoon_tomato(canvas, center_x, y, 118 * body_scale_x, 112 * body_scale_y, eye_offset) + + if progress > 0.55: + info_alpha_color = self.colors["leaf"] if progress > 0.72 else self.colors["muted"] + canvas.create_text( + center_x, + y + 126, + text="视觉 ready · 日志 ready · 参数 ready", + fill=info_alpha_color, + font=self.fonts["default"], + ) + + if frame_index + 1 < total_frames: + self.root.after(28, lambda: self._animate_tomato_frame(frame_index + 1, total_frames)) + else: + self.root.after(220, self.finish_startup_animation) + + def draw_cartoon_tomato(self, canvas, center_x, center_y, body_w, body_h, eye_offset): + """在 Canvas 上绘制卡通番茄。""" + left = center_x - body_w / 2 + top = center_y - body_h / 2 + right = center_x + body_w / 2 + bottom = center_y + body_h / 2 + canvas.create_oval(left + 8, top + 10, right + 8, bottom + 14, fill="#D9A18F", outline="") + canvas.create_oval(left, top, right, bottom, fill=self.colors["accent"], outline="") + canvas.create_oval(left + 16, top + 14, center_x - 6, center_y + 12, fill="#FF8B72", outline="") + canvas.create_oval(left + 20, top + 18, left + 42, top + 42, fill="#FFEDE7", outline="") + canvas.create_polygon( + center_x - 10, top - 6, + center_x, top - 24, + center_x + 10, top - 6, + center_x + 22, top - 20, + center_x + 16, top + 2, + center_x - 16, top + 2, + center_x - 22, top - 20, + fill=self.colors["leaf"], + outline="", + ) + canvas.create_line(center_x, top - 4, center_x + 6, top - 26, fill="#7B512F", width=3, smooth=True) + canvas.create_oval(center_x - 24, center_y - 8 + eye_offset, center_x - 14, center_y + 4 + eye_offset, fill=self.colors["text"], outline="") + canvas.create_oval(center_x + 14, center_y - 8 - eye_offset, center_x + 24, center_y + 4 - eye_offset, fill=self.colors["text"], outline="") + canvas.create_oval(center_x - 21, center_y - 5 + eye_offset, center_x - 18, center_y - 2 + eye_offset, fill="#FFF8F4", outline="") + canvas.create_oval(center_x + 17, center_y - 5 - eye_offset, center_x + 20, center_y - 2 - eye_offset, fill="#FFF8F4", outline="") + canvas.create_arc(center_x - 18, center_y + 4, center_x + 18, center_y + 28, start=200, extent=140, style=tk.ARC, outline="#FFF8F4", width=3) + canvas.create_oval(center_x - 42, center_y + 14, center_x - 26, center_y + 24, fill="#F48B8E", outline="") + canvas.create_oval(center_x + 26, center_y + 14, center_x + 42, center_y + 24, fill="#F48B8E", outline="") + + def finish_startup_animation(self): + """移除启动动画遮罩。""" + if self.startup_overlay is not None: + self.startup_overlay.destroy() + self.startup_overlay = None + if self.startup_window is not None: + self.startup_window.destroy() + self.startup_window = None + self.root.deiconify() + self.root.lift() + self.root.focus_force() + def get_resource_candidates(self, filename): """返回项目内可能存放资源文件的候选路径。""" return [ self.base_dir / filename, + self.base_dir / "models" / filename, self.base_dir / "tools" / filename, + self.app_dir / filename, + self.app_dir / "models" / filename, + self.app_dir / "tools" / filename, Path.cwd() / filename, + Path.cwd() / "models" / filename, Path.cwd() / "tools" / filename, ] @@ -608,11 +888,20 @@ class TomatoHarvestingUI: """解析绝对路径或相对项目根目录的路径。""" if path_value: candidate = Path(path_value).expanduser() - if not candidate.is_absolute(): - candidate = self.base_dir / candidate - candidate = candidate.resolve() - if candidate.exists(): - return candidate + if candidate.is_absolute(): + candidate = candidate.resolve() + if candidate.exists(): + return candidate + else: + relative_candidates = [ + self.app_dir / candidate, + self.base_dir / candidate, + Path.cwd() / candidate, + ] + for relative_candidate in relative_candidates: + resolved = relative_candidate.resolve() + if resolved.exists(): + return resolved if fallback_name: for candidate in self.get_resource_candidates(fallback_name): if candidate.exists(): @@ -730,7 +1019,8 @@ class TomatoHarvestingUI: self.agv_speed_stop.insert(0, "0.0") self.total_duration.insert(0, "300") self.agv_stop_timeout.insert(0, "10") - self.yolo_model_path.insert(0, "best.pt") + default_model_path = self.resolve_path("models/best.pt", fallback_name="best.pt") + self.yolo_model_path.insert(0, str(default_model_path) if default_model_path else "models/best.pt") default_bg_path = self.resolve_path("", fallback_name="1.png") if default_bg_path: self.camera_bg_path.insert(0, str(default_bg_path)) @@ -757,7 +1047,9 @@ class TomatoHarvestingUI: self.agv_stop_timeout.delete(0, tk.END) self.agv_stop_timeout.insert(0, str(settings.get("AGV_STOP_TIMEOUT", "10"))) self.yolo_model_path.delete(0, tk.END) - self.yolo_model_path.insert(0, str(settings.get("YOLO_MODEL_PATH", "best.pt"))) + saved_model_path = settings.get("YOLO_MODEL_PATH", "models/best.pt") + resolved_model_path = self.resolve_path(saved_model_path, fallback_name="best.pt") + self.yolo_model_path.insert(0, str(resolved_model_path) if resolved_model_path else str(saved_model_path)) self.camera_bg_path.delete(0, tk.END) saved_bg_path = settings.get("CAMERA_BG_PATH", "") resolved_bg_path = self.resolve_path(saved_bg_path, fallback_name="1.png")