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.
This commit is contained in:
Brunsmeier
2026-05-21 11:26:31 +08:00
parent 02657c18f8
commit cf8908fcc1
5 changed files with 569 additions and 78 deletions

View File

@ -113,6 +113,56 @@ start_tomatopick.bat
如果需要桌面入口,可以自行在桌面创建快捷方式,并把目标指向 `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。 - `启动程序`运行完整采摘流程会连接机械臂、AGV、RealSense 并加载 YOLO。

View File

@ -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",
)

View File

@ -27,7 +27,7 @@ AGV_RUN_DISTANCE = 5.0
TOTAL_DURATION = 300 TOTAL_DURATION = 300
AGV_STOP_TIMEOUT = 10 AGV_STOP_TIMEOUT = 10
AGV_PICK_SETTLE_DELAY = 1.5 AGV_PICK_SETTLE_DELAY = 1.5
YOLO_MODEL_PATH = "best.pt" YOLO_MODEL_PATH = "models/best.pt"
YOLO_DETECT_CONF = 0.5 YOLO_DETECT_CONF = 0.5
PICK_CONFIDENCE_THRESHOLD = 0.7 PICK_CONFIDENCE_THRESHOLD = 0.7

View File

@ -6,6 +6,7 @@ import os
import sys import sys
import threading import threading
import time import time
from pathlib import Path
import cv2 import cv2
import numpy as np import numpy as np
@ -105,6 +106,61 @@ ui_callback: Callable[[np.ndarray], None] = lambda frame: None
robot_rpc_client = pyaubo_sdk.RpcClient() 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: def configure(config: Dict[str, Any]) -> None:
"""接收 control.py 同步过来的可调参数。""" """接收 control.py 同步过来的可调参数。"""
_runtime_config.update(config) _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: def init_tomato_detector(model_path: Optional[str] = None) -> Any:
"""初始化 YOLO 模型。""" """初始化 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}") print(f"加载 YOLO 模型 | 路径: {use_path}")
if not os.path.exists(use_path): if not os.path.exists(use_path):

View File

@ -8,6 +8,7 @@ import os
import sys import sys
import json import json
import io import io
import math
import ctypes import ctypes
from ctypes import wintypes from ctypes import wintypes
from pathlib import Path from pathlib import Path
@ -33,6 +34,7 @@ import contextlib
# Windows 控制台缓冲区结构体。 # Windows 控制台缓冲区结构体。
# 当程序在 Windows 上运行时,会用它读取后台线程输出的控制台内容, # 当程序在 Windows 上运行时,会用它读取后台线程输出的控制台内容,
# 再把这些内容转发到 Tkinter 的日志面板中。 # 再把这些内容转发到 Tkinter 的日志面板中。
if sys.platform == "win32":
class CONSOLE_SCREEN_BUFFER_INFO(ctypes.Structure): class CONSOLE_SCREEN_BUFFER_INFO(ctypes.Structure):
_fields_ = [ _fields_ = [
("dwSize", wintypes._COORD), ("dwSize", wintypes._COORD),
@ -44,9 +46,28 @@ class CONSOLE_SCREEN_BUFFER_INFO(ctypes.Structure):
STD_OUTPUT_HANDLE = -11 STD_OUTPUT_HANDLE = -11
kernel32 = ctypes.WinDLL('kernel32', use_last_error=True) kernel32 = ctypes.WinDLL('kernel32', use_last_error=True)
else:
CONSOLE_SCREEN_BUFFER_INFO = None
STD_OUTPUT_HANDLE = None
kernel32 = None
MAX_LOG_LINES = 1000 MAX_LOG_LINES = 1000
MAX_TERMINAL_LINES = 1500 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: 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.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_width = window_w
self.base_window_height = window_h self.base_window_height = window_h
self.base_dir = Path(__file__).resolve().parent self.base_dir = get_runtime_resource_dir()
self.settings_file = self.base_dir / "ui_settings.json" self.app_dir = get_runtime_app_dir()
self.settings_file = self.app_dir / "ui_settings.json"
self._font_resize_job = None self._font_resize_job = None
self._is_applying_font_scale = False self._is_applying_font_scale = False
self.colors = { self.colors = {
"bg": "#F3F7FB", "bg": "#FFF4EC",
"panel": "#FFFFFF", "bg_deep": "#F7E0D0",
"panel_soft": "#F8FBFD", "panel": "#FFFDFB",
"border": "#D8E3EC", "panel_soft": "#FFF2E8",
"text": "#16324A", "panel_alt": "#FBE4D7",
"muted": "#6B7C93", "border": "#E8C9B8",
"accent": "#0F766E", "text": "#452315",
"accent_soft": "#D7F3EE", "muted": "#8A5E50",
"danger": "#B45309", "accent": "#D84A32",
"danger_soft": "#FDE7D7", "accent_deep": "#A92F22",
"info": "#0F4C81", "accent_soft": "#FFD9CF",
"info_soft": "#DCEBFA", "accent_gold": "#F4B860",
"terminal_bg": "#0F172A", "leaf": "#3F8C53",
"terminal_fg": "#DCFCE7", "leaf_soft": "#D8F0D8",
"terminal_muted": "#93C5FD", "danger": "#A63E2A",
"terminal_err": "#FCA5A5", "danger_soft": "#FFE0D6",
"metric_1": "#E6FFFB", "info": "#8F3B2E",
"metric_2": "#EEF2FF", "info_soft": "#FFE7D9",
"metric_3": "#FFF7ED", "terminal_bg": "#2A1410",
"metric_4": "#F0FDF4", "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_image = None
self.camera_bg_image = None # 保存背景图引用(关键:防止回收) self.camera_bg_image = None # 保存背景图引用(关键:防止回收)
self.raw_bg_image = None # 新增保存原始1.png用于后续尺寸调整 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() self.create_widgets()
@ -128,6 +164,7 @@ class TomatoHarvestingUI:
# 启动日志处理线程 # 启动日志处理线程
self.root.after(200, self.process_log_queue) self.root.after(200, self.process_log_queue)
self.root.after(20, self.play_startup_animation)
# 信号处理 # 信号处理
signal.signal(signal.SIGINT, self.signal_handler) signal.signal(signal.SIGINT, self.signal_handler)
@ -138,7 +175,7 @@ class TomatoHarvestingUI:
# 设置主窗口背景 # 设置主窗口背景
self.set_background_image() 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) main_frame.pack(fill=tk.BOTH, expand=True)
header_card = tk.Frame( header_card = tk.Frame(
@ -148,16 +185,26 @@ class TomatoHarvestingUI:
highlightthickness=1, highlightthickness=1,
highlightbackground=self.colors["border"] 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 = 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( self.status_badge = tk.Label(
header_top, header_top,
text="系统就绪", text="系统就绪",
font=self.fonts["button"], font=self.fonts["button"],
bg=self.colors["accent_soft"], bg=self.colors["accent_soft"],
fg=self.colors["accent"], fg=self.colors["accent_deep"],
padx=14, padx=16,
pady=6 pady=6
) )
self.status_badge.pack(side=tk.RIGHT) self.status_badge.pack(side=tk.RIGHT)
@ -165,7 +212,7 @@ class TomatoHarvestingUI:
title_wrap.pack(side=tk.LEFT, fill=tk.X, expand=True) title_wrap.pack(side=tk.LEFT, fill=tk.X, expand=True)
self.title_label = tk.Label( self.title_label = tk.Label(
title_wrap, title_wrap,
text="番茄采摘系统", text="番茄采摘控制台",
font=self.fonts["title"], font=self.fonts["title"],
bg=self.colors["panel"], bg=self.colors["panel"],
fg=self.colors["text"] fg=self.colors["text"]
@ -173,12 +220,27 @@ class TomatoHarvestingUI:
self.title_label.pack(side=tk.LEFT) self.title_label.pack(side=tk.LEFT)
self.subtitle_label = tk.Label( self.subtitle_label = tk.Label(
title_wrap, title_wrap,
text="参数配置 · 日志 · 相机 · 终端", text="参数配置 · 视觉联调 · 设备控制 · 终端回显",
font=self.fonts["subtitle"], font=self.fonts["subtitle"],
bg=self.colors["panel"], bg=self.colors["panel"],
fg=self.colors["muted"] fg=self.colors["muted"]
) )
self.subtitle_label.pack(side=tk.LEFT, padx=(14, 0), pady=(4, 0)) 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 = ttk.Frame(main_frame, style="App.TFrame")
body_frame.pack(fill=tk.BOTH, expand=True) 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 = 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.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") camera_card = ttk.LabelFrame(center_frame, text="相机画面", padding="8", style="Card.TLabelframe")
@ -384,9 +446,9 @@ class TomatoHarvestingUI:
bd=0, bd=0,
relief="flat", relief="flat",
highlightthickness=0, highlightthickness=0,
bg="#111111", bg=self.colors["terminal_bg"],
fg="#D8F3DC", fg=self.colors["terminal_fg"],
insertbackground="#D8F3DC" insertbackground=self.colors["terminal_fg"]
) )
self.terminal_text.pack(fill=tk.BOTH, expand=True, pady=5) self.terminal_text.pack(fill=tk.BOTH, expand=True, pady=5)
self.terminal_text.config(state=tk.DISABLED) self.terminal_text.config(state=tk.DISABLED)
@ -400,23 +462,28 @@ class TomatoHarvestingUI:
def init_fonts(self): 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 = { self.font_specs = {
"default": {"family": "微软雅黑", "size": 9}, "default": {"family": primary_family, "size": 10},
"title": {"family": "微软雅黑", "size": 18, "weight": "bold"}, "title": {"family": title_family, "size": 20, "weight": "bold"},
"subtitle": {"family": "微软雅黑", "size": 10}, "subtitle": {"family": primary_family, "size": 10},
"section": {"family": "微软雅黑", "size": 9, "weight": "bold"}, "section": {"family": primary_family, "size": 10, "weight": "bold"},
"button": {"family": "微软雅黑", "size": 9, "weight": "bold"}, "button": {"family": primary_family, "size": 10, "weight": "bold"},
"button_normal": {"family": "微软雅黑", "size": 9}, "button_normal": {"family": primary_family, "size": 10},
"mono": {"family": "微软雅黑", "size": 9}, "mono": {"family": mono_family, "size": 9},
"chip": {"family": primary_family, "size": 9, "weight": "bold"},
} }
self.font_min_sizes = { self.font_min_sizes = {
"default": 7, "default": 8,
"title": 14, "title": 15,
"subtitle": 8, "subtitle": 8,
"section": 8, "section": 8,
"button": 8, "button": 8,
"button_normal": 8, "button_normal": 8,
"mono": 8, "mono": 8,
"chip": 8,
} }
self.fonts = {} self.fonts = {}
for name, spec in self.font_specs.items(): for name, spec in self.font_specs.items():
@ -429,6 +496,14 @@ class TomatoHarvestingUI:
self.root.option_add("*Font", self.fonts["default"]) self.root.option_add("*Font", self.fonts["default"])
self.root.bind("<Configure>", self.on_root_resize) self.root.bind("<Configure>", 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): def setup_styles(self):
"""集中配置 ttk 控件样式,避免样式定义散落在各个方法中。""" """集中配置 ttk 控件样式,避免样式定义散落在各个方法中。"""
style = ttk.Style() style = ttk.Style()
@ -442,33 +517,47 @@ class TomatoHarvestingUI:
style.configure("App.TFrame", background=self.colors["bg"]) style.configure("App.TFrame", background=self.colors["bg"])
style.configure("Transparent.TFrame", background=self.colors["bg"]) style.configure("Transparent.TFrame", background=self.colors["bg"])
style.configure("TPanedwindow", 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("TLabel", background=self.colors["panel"], foreground=self.colors["text"], font=self.fonts["default"])
style.configure( style.configure(
"TEntry", "TEntry",
fieldbackground=self.colors["panel"], fieldbackground=self.colors["panel"],
background=self.colors["panel"], background=self.colors["panel"],
foreground=self.colors["text"], foreground=self.colors["text"],
padding=5, padding=7,
font=self.fonts["default"], font=self.fonts["default"],
relief="flat", 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( style.configure(
"TCombobox", "TCombobox",
fieldbackground=self.colors["panel"], fieldbackground=self.colors["panel"],
background=self.colors["panel"], background=self.colors["panel"],
foreground=self.colors["text"], foreground=self.colors["text"],
arrowsize=14, arrowsize=14,
padding=3, padding=4,
font=self.fonts["default"] font=self.fonts["default"]
) )
style.configure( style.configure(
"TButton", "TButton",
font=self.fonts["button_normal"], font=self.fonts["button_normal"],
padding=(10, 6), padding=(12, 8),
relief="flat", relief="flat",
borderwidth=0 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( style.configure(
"SidebarShell.TLabelframe", "SidebarShell.TLabelframe",
background=self.colors["panel_soft"], background=self.colors["panel_soft"],
@ -487,12 +576,12 @@ class TomatoHarvestingUI:
background=self.colors["panel"], background=self.colors["panel"],
borderwidth=1, borderwidth=1,
relief="solid", relief="solid",
padding=6 padding=8
) )
style.configure( style.configure(
"SidebarCard.TLabelframe.Label", "SidebarCard.TLabelframe.Label",
background=self.colors["panel"], background=self.colors["panel"],
foreground=self.colors["text"], foreground=self.colors["accent_deep"],
font=self.fonts["section"] font=self.fonts["section"]
) )
style.configure( style.configure(
@ -500,33 +589,33 @@ class TomatoHarvestingUI:
background=self.colors["panel"], background=self.colors["panel"],
borderwidth=1, borderwidth=1,
relief="solid", relief="solid",
padding=6 padding=8
) )
style.configure( style.configure(
"Card.TLabelframe.Label", "Card.TLabelframe.Label",
background=self.colors["panel"], background=self.colors["panel"],
foreground=self.colors["text"], foreground=self.colors["accent_deep"],
font=self.fonts["section"] font=self.fonts["section"]
) )
style.configure( style.configure(
"TerminalCard.TLabelframe", "TerminalCard.TLabelframe",
background=self.colors["panel"], background=self.colors["panel_alt"],
borderwidth=1, borderwidth=1,
relief="solid", relief="solid",
padding=6 padding=8
) )
style.configure( style.configure(
"TerminalCard.TLabelframe.Label", "TerminalCard.TLabelframe.Label",
background=self.colors["panel"], background=self.colors["panel_alt"],
foreground=self.colors["text"], foreground=self.colors["accent_deep"],
font=self.fonts["section"] font=self.fonts["section"]
) )
style.configure("Primary.TButton", background=self.colors["accent"], foreground="#FFFFFF", font=self.fonts["button"]) style.configure("Primary.TButton", background=self.colors["accent"], foreground="#FFF8F4", font=self.fonts["button"])
style.map("Primary.TButton", background=[("active", "#0B5E58"), ("disabled", "#A8D7D2")], foreground=[("disabled", "#F7FAFC")]) style.map("Primary.TButton", background=[("active", self.colors["accent_deep"]), ("disabled", "#F1B5AA")], foreground=[("disabled", "#FFF6F2")])
style.configure("Danger.TButton", background="#C2410C", foreground="#FFFFFF", font=self.fonts["button"]) style.configure("Danger.TButton", background="#B84531", foreground="#FFF8F4", font=self.fonts["button"])
style.map("Danger.TButton", background=[("active", "#9A3412"), ("disabled", "#F3B99A")], foreground=[("disabled", "#FEF2F2")]) 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.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): def on_root_resize(self, event):
"""窗口尺寸变化时,延迟刷新字体缩放。""" """窗口尺寸变化时,延迟刷新字体缩放。"""
@ -556,6 +645,41 @@ class TomatoHarvestingUI:
finally: finally:
self._is_applying_font_scale = False 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"): def set_status_badge(self, text, tone="idle"):
"""更新顶部状态徽标。""" """更新顶部状态徽标。"""
tone_map = { tone_map = {
@ -595,12 +719,168 @@ class TomatoHarvestingUI:
except Exception: except Exception:
pass 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): def get_resource_candidates(self, filename):
"""返回项目内可能存放资源文件的候选路径。""" """返回项目内可能存放资源文件的候选路径。"""
return [ return [
self.base_dir / filename, self.base_dir / filename,
self.base_dir / "models" / filename,
self.base_dir / "tools" / 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() / filename,
Path.cwd() / "models" / filename,
Path.cwd() / "tools" / filename, Path.cwd() / "tools" / filename,
] ]
@ -608,11 +888,20 @@ class TomatoHarvestingUI:
"""解析绝对路径或相对项目根目录的路径。""" """解析绝对路径或相对项目根目录的路径。"""
if path_value: if path_value:
candidate = Path(path_value).expanduser() candidate = Path(path_value).expanduser()
if not candidate.is_absolute(): if candidate.is_absolute():
candidate = self.base_dir / candidate
candidate = candidate.resolve() candidate = candidate.resolve()
if candidate.exists(): if candidate.exists():
return candidate 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: if fallback_name:
for candidate in self.get_resource_candidates(fallback_name): for candidate in self.get_resource_candidates(fallback_name):
if candidate.exists(): if candidate.exists():
@ -730,7 +1019,8 @@ class TomatoHarvestingUI:
self.agv_speed_stop.insert(0, "0.0") self.agv_speed_stop.insert(0, "0.0")
self.total_duration.insert(0, "300") self.total_duration.insert(0, "300")
self.agv_stop_timeout.insert(0, "10") 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") default_bg_path = self.resolve_path("", fallback_name="1.png")
if default_bg_path: if default_bg_path:
self.camera_bg_path.insert(0, str(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.delete(0, tk.END)
self.agv_stop_timeout.insert(0, str(settings.get("AGV_STOP_TIMEOUT", "10"))) 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.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) self.camera_bg_path.delete(0, tk.END)
saved_bg_path = settings.get("CAMERA_BG_PATH", "") saved_bg_path = settings.get("CAMERA_BG_PATH", "")
resolved_bg_path = self.resolve_path(saved_bg_path, fallback_name="1.png") resolved_bg_path = self.resolve_path(saved_bg_path, fallback_name="1.png")