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`
## 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。

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
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

View File

@ -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):

View File

@ -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,6 +34,7 @@ import contextlib
# Windows 控制台缓冲区结构体。
# 当程序在 Windows 上运行时,会用它读取后台线程输出的控制台内容,
# 再把这些内容转发到 Tkinter 的日志面板中。
if sys.platform == "win32":
class CONSOLE_SCREEN_BUFFER_INFO(ctypes.Structure):
_fields_ = [
("dwSize", wintypes._COORD),
@ -44,9 +46,28 @@ class CONSOLE_SCREEN_BUFFER_INFO(ctypes.Structure):
STD_OUTPUT_HANDLE = -11
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_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("<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):
"""集中配置 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
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")