Refactor control parameters and enhance depth handling for tomato harvesting

- Updated YOLO model path to a relative path.
- Introduced new parameters for depth handling, including minimum and maximum depth, fallback settings, and pixel validation thresholds.
- Increased maximum pick angle to accommodate wider fruit stems.
- Added cut pull assist feature with configurable options.
- Removed unused resource path resolution functions from control_core.py.
- Simplified resource path handling in main.py.
- Updated UI colors and styles for improved aesthetics.
- Removed outdated requirements.txt file.
This commit is contained in:
Brunsmeier
2026-07-14 15:12:08 +08:00
parent b1b17ba9f5
commit 47c08a6466
6 changed files with 180 additions and 604 deletions

View File

@ -113,56 +113,6 @@ 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。
@ -234,6 +184,16 @@ best.pt
模型类别、关键点顺序和训练定义需要与上述逻辑一致。 模型类别、关键点顺序和训练定义需要与上述逻辑一致。
### 真实果梗的适配
采摘流程仍保持原来的两关键点和单次剪刀动作:第 0 点用于定位采摘点,第 1 点只用于计算末端角度。为适应真实串番茄,控制参数新增了以下处理:
- 剪切点深度无效时,先扩大邻域取中位深度;仍失败时沿第 0 点到第 1 点的果梗方向取有限次数的邻近深度,并将该深度用于剪切点反投影。
- 关键点连线按无方向直线处理,方向点在剪切点的上方、下方或侧方均可;默认最大末端旋转已提高到 $70^\circ$。
- `CUT_PULL_ASSIST_ENABLED` 默认关闭。没有“已剪断”传感反馈时,程序不能可靠判断剪刀是否剪断了老梗。确认撤离方向、碰撞空间和末端承载能力后,才可开启该项,以在原有撤离前增加一个小的 Y 向拉拽动作。
首次调试建议保持辅助拉拽关闭,先以低速确认深度回退输出、关键点角度和机械臂姿态均正确。
## 运行前检查 ## 运行前检查
完整采摘流程会控制真实硬件。启动前请确认: 完整采摘流程会控制真实硬件。启动前请确认:

View File

@ -1,91 +0,0 @@
# -*- 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,10 +27,22 @@ 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 = "models/best.pt" YOLO_MODEL_PATH = "best.pt"
YOLO_DETECT_CONF = 0.5 YOLO_DETECT_CONF = 0.5
PICK_CONFIDENCE_THRESHOLD = 0.7 PICK_CONFIDENCE_THRESHOLD = 0.7
# 真实果梗较细时,剪切点像素可能没有直接深度。先使用剪切点邻域深度,
# 失败后可沿关键点 0 -> 关键点 1 的方向寻找同一果串的可靠深度。
PICK_DEPTH_MIN_M = 0.18
PICK_DEPTH_MAX_M = 1.20
PICK_DEPTH_WINDOW_RADIUS = 5
PICK_DEPTH_FALLBACK_WINDOW_RADIUS = 14
PICK_DEPTH_MIN_VALID_PIXELS = 5
PICK_DEPTH_MEDIAN_TOLERANCE_M = 0.05
PICK_DEPTH_LINE_FALLBACK_ENABLED = True
PICK_DEPTH_LINE_FALLBACK_STEPS = 5
PICK_DEPTH_LINE_FALLBACK_STEP_PX = 8
# -------------------- 位姿与轨迹参数 -------------------- # -------------------- 位姿与轨迹参数 --------------------
# Home 是机械臂的初始/复位关节位。 # Home 是机械臂的初始/复位关节位。
@ -45,7 +57,13 @@ PICKUP_X_OFFSET = 0.0
PICKUP_Y_OFFSET = -0.013 PICKUP_Y_OFFSET = -0.013
PICK_ANGLE_ORIENTATION_INDEX = 4 PICK_ANGLE_ORIENTATION_INDEX = 4
PICK_ANGLE_SIGN = 1.0 PICK_ANGLE_SIGN = 1.0
MAX_PICK_ANGLE_DEG = 30.0 # 真实果梗可能接近横向;仍受限于此值,避免因姿态变化过大造成不可达或碰撞。
MAX_PICK_ANGLE_DEG = 70.0
# 默认关闭。剪刀缺少“已剪断”反馈时,不能自动判断是否应强拉。
# 现场确认撤离方向安全后,才可启用小距离辅助拉拽。
CUT_PULL_ASSIST_ENABLED = False
CUT_PULL_ASSIST_Y_OFFSET = 0.03
# 当前项目只使用一个放置位,仍保留列表结构以兼容 control_core。 # 当前项目只使用一个放置位,仍保留列表结构以兼容 control_core。
place_positions = [[-0.6203, 0.026, 0.1078, 2.9923, 0.0366, -1.4157]] place_positions = [[-0.6203, 0.026, 0.1078, 2.9923, 0.0366, -1.4157]]
@ -78,6 +96,15 @@ _CONFIG_NAMES = (
"YOLO_MODEL_PATH", "YOLO_MODEL_PATH",
"YOLO_DETECT_CONF", "YOLO_DETECT_CONF",
"PICK_CONFIDENCE_THRESHOLD", "PICK_CONFIDENCE_THRESHOLD",
"PICK_DEPTH_MIN_M",
"PICK_DEPTH_MAX_M",
"PICK_DEPTH_WINDOW_RADIUS",
"PICK_DEPTH_FALLBACK_WINDOW_RADIUS",
"PICK_DEPTH_MIN_VALID_PIXELS",
"PICK_DEPTH_MEDIAN_TOLERANCE_M",
"PICK_DEPTH_LINE_FALLBACK_ENABLED",
"PICK_DEPTH_LINE_FALLBACK_STEPS",
"PICK_DEPTH_LINE_FALLBACK_STEP_PX",
"HOME_JOINTS", "HOME_JOINTS",
"APPROACH_Y_OFFSET", "APPROACH_Y_OFFSET",
"LIFT_Z_OFFSET", "LIFT_Z_OFFSET",
@ -86,6 +113,8 @@ _CONFIG_NAMES = (
"PICK_ANGLE_ORIENTATION_INDEX", "PICK_ANGLE_ORIENTATION_INDEX",
"PICK_ANGLE_SIGN", "PICK_ANGLE_SIGN",
"MAX_PICK_ANGLE_DEG", "MAX_PICK_ANGLE_DEG",
"CUT_PULL_ASSIST_ENABLED",
"CUT_PULL_ASSIST_Y_OFFSET",
"GRIPPER_CLOSE_IO", "GRIPPER_CLOSE_IO",
"GRIPPER_OPEN_IO", "GRIPPER_OPEN_IO",
"GRIPPER_ACTION_DELAY", "GRIPPER_ACTION_DELAY",

View File

@ -6,7 +6,6 @@ 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
@ -40,13 +39,6 @@ PICK_ENDPOINT_KEYPOINT_INDEX = 1 # YOLO pose 第 1 个点为方向端点 endpoi
PICK_KEYPOINT_BOX_MARGIN_RATIO = 0.15 # 关键点允许略超出 bbox 的比例。 PICK_KEYPOINT_BOX_MARGIN_RATIO = 0.15 # 关键点允许略超出 bbox 的比例。
PICK_CUTPOINT_MAX_REL_Y = 0.70 # 采摘点在 bbox 内的最大相对高度,防止误取下方点。 PICK_CUTPOINT_MAX_REL_Y = 0.70 # 采摘点在 bbox 内的最大相对高度,防止误取下方点。
PICK_KEYPOINT_MIN_DISTANCE_PX = 8.0 # cutpoint 与 endpoint 的最小像素距离。 PICK_KEYPOINT_MIN_DISTANCE_PX = 8.0 # cutpoint 与 endpoint 的最小像素距离。
PICK_ENDPOINT_Y_TOLERANCE_PX = 4 # endpoint 允许比 cutpoint 略高的像素容差。
PICK_DEPTH_WINDOW_RADIUS = 5 # 首次稳健取深度的窗口半径5 表示 11x11。
PICK_DEPTH_FALLBACK_WINDOW_RADIUS = 10 # 首次深度不足时的扩大窗口半径10 表示 21x21。
PICK_DEPTH_MIN_VALID_PIXELS = 5 # 深度窗口内至少需要的有效深度点数量。
PICK_DEPTH_MIN_M = 0.25 # 有效深度下限,单位 m。
PICK_DEPTH_MAX_M = 0.6 # 有效深度上限,单位 m。
PICK_DEPTH_MEDIAN_TOLERANCE_M = 0.035 # 深度中位数滤波容差,单位 m。
# R_tc = np.array( # R_tc = np.array(
# [ # [
@ -106,61 +98,6 @@ 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)
@ -804,7 +741,21 @@ class RobotArmController:
self.close_scissors_for_pick() self.close_scissors_for_pick()
retreat_joints = self.move_pose(approach_pose, pickup_joints, description="采摘后返回预抓取点") retreat_reference_joints = pickup_joints
if _cfg("CUT_PULL_ASSIST_ENABLED"):
pull_pose = pickup_pose.copy()
pull_pose[1] += _cfg("CUT_PULL_ASSIST_Y_OFFSET")
print(
"机械臂:执行剪切后辅助拉拽 "
f"Y 偏移 {_cfg('CUT_PULL_ASSIST_Y_OFFSET'):.3f} m"
)
pull_joints = self.move_pose(pull_pose, pickup_joints, description="剪切后辅助拉拽")
if pull_joints is None:
self.return_home()
return False
retreat_reference_joints = pull_joints
retreat_joints = self.move_pose(approach_pose, retreat_reference_joints, description="采摘后返回预抓取点")
if retreat_joints is None: if retreat_joints is None:
self.return_home() self.return_home()
return False return False
@ -854,9 +805,7 @@ 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 模型。"""
configured_path = model_path or _cfg("YOLO_MODEL_PATH") or "models/best.pt" use_path = model_path or _cfg("YOLO_MODEL_PATH") or "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):
@ -986,7 +935,13 @@ def calculate_keypoint_line_angle_rad(
if abs(dx) < 1e-6 and abs(dy) < 1e-6: if abs(dx) < 1e-6 and abs(dy) < 1e-6:
return 0.0 return 0.0
# 果梗是一条无方向直线endpoint 在剪切点的任一侧都应表示同一倾角。
# 归一化后,横向或斜向果梗不会因关键点标注顺序而得到 180°旋转。
angle_rad = math.atan2(dx, dy) angle_rad = math.atan2(dx, dy)
if angle_rad > math.pi / 2:
angle_rad -= math.pi
elif angle_rad <= -math.pi / 2:
angle_rad += math.pi
max_angle_rad = math.radians(float(_cfg("MAX_PICK_ANGLE_DEG"))) max_angle_rad = math.radians(float(_cfg("MAX_PICK_ANGLE_DEG")))
return max(-max_angle_rad, min(max_angle_rad, angle_rad)) return max(-max_angle_rad, min(max_angle_rad, angle_rad))
@ -1131,10 +1086,6 @@ class VisionController:
print(f"Pick candidate skipped: keypoints too close ({distance:.1f}px)") print(f"Pick candidate skipped: keypoints too close ({distance:.1f}px)")
return False return False
if end_point[1] + PICK_ENDPOINT_Y_TOLERANCE_PX < cutpoint[1]:
print(f"Pick candidate skipped: endpoint appears above cutpoint @ cut={cutpoint}, end={end_point}")
return False
return True return True
def extract_candidate_keypoints( def extract_candidate_keypoints(
@ -1271,7 +1222,7 @@ class VisionController:
for sample_y in range(y_start, y_end + 1): for sample_y in range(y_start, y_end + 1):
for sample_x in range(x_start, x_end + 1): for sample_x in range(x_start, x_end + 1):
depth = float(depth_frame.get_distance(sample_x, sample_y)) depth = float(depth_frame.get_distance(sample_x, sample_y))
if PICK_DEPTH_MIN_M <= depth <= PICK_DEPTH_MAX_M: if _cfg("PICK_DEPTH_MIN_M") <= depth <= _cfg("PICK_DEPTH_MAX_M"):
samples.append(depth) samples.append(depth)
return samples return samples
@ -1283,25 +1234,27 @@ class VisionController:
frame_shape: Tuple[int, int, int], frame_shape: Tuple[int, int, int],
) -> Optional[float]: ) -> Optional[float]:
center_depth = float(depth_frame.get_distance(pixel_x, pixel_y)) center_depth = float(depth_frame.get_distance(pixel_x, pixel_y))
for radius in (PICK_DEPTH_WINDOW_RADIUS, PICK_DEPTH_FALLBACK_WINDOW_RADIUS): for radius in (_cfg("PICK_DEPTH_WINDOW_RADIUS"), _cfg("PICK_DEPTH_FALLBACK_WINDOW_RADIUS")):
samples = self.collect_depth_samples(depth_frame, pixel_x, pixel_y, frame_shape, radius) samples = self.collect_depth_samples(depth_frame, pixel_x, pixel_y, frame_shape, radius)
if len(samples) < PICK_DEPTH_MIN_VALID_PIXELS: if len(samples) < _cfg("PICK_DEPTH_MIN_VALID_PIXELS"):
continue continue
sample_array = np.array(samples, dtype=float) sample_array = np.array(samples, dtype=float)
median_depth = float(np.median(sample_array)) median_depth = float(np.median(sample_array))
stable_samples = sample_array[np.abs(sample_array - median_depth) <= PICK_DEPTH_MEDIAN_TOLERANCE_M] stable_samples = sample_array[
if len(stable_samples) >= PICK_DEPTH_MIN_VALID_PIXELS: np.abs(sample_array - median_depth) <= _cfg("PICK_DEPTH_MEDIAN_TOLERANCE_M")
]
if len(stable_samples) >= _cfg("PICK_DEPTH_MIN_VALID_PIXELS"):
depth = float(np.median(stable_samples)) depth = float(np.median(stable_samples))
else: else:
depth = median_depth depth = median_depth
if not (PICK_DEPTH_MIN_M <= center_depth <= PICK_DEPTH_MAX_M): if not (_cfg("PICK_DEPTH_MIN_M") <= center_depth <= _cfg("PICK_DEPTH_MAX_M")):
print( print(
"采摘点中心深度无效,使用邻域稳健深度 " "采摘点中心深度无效,使用邻域稳健深度 "
f"{depth:.3f}m @ ({pixel_x}, {pixel_y}), radius={radius}" f"{depth:.3f}m @ ({pixel_x}, {pixel_y}), radius={radius}"
) )
elif abs(center_depth - depth) > PICK_DEPTH_MEDIAN_TOLERANCE_M: elif abs(center_depth - depth) > _cfg("PICK_DEPTH_MEDIAN_TOLERANCE_M"):
print( print(
"采摘点中心深度跳变,使用邻域稳健深度 " "采摘点中心深度跳变,使用邻域稳健深度 "
f"{depth:.3f}m 替代 {center_depth:.3f}m @ ({pixel_x}, {pixel_y})" f"{depth:.3f}m 替代 {center_depth:.3f}m @ ({pixel_x}, {pixel_y})"
@ -1314,6 +1267,37 @@ class VisionController:
) )
return None return None
def get_cutpoint_depth(
self,
depth_frame: Any,
cutpoint: Tuple[int, int],
end_point: Optional[Tuple[int, int]],
frame_shape: Tuple[int, int, int],
) -> Optional[float]:
"""获取剪切点深度;细果梗无深度时沿原有关键点连线做有限回退。"""
depth = self.get_robust_depth(depth_frame, cutpoint[0], cutpoint[1], frame_shape)
if depth is not None:
return depth
if not _cfg("PICK_DEPTH_LINE_FALLBACK_ENABLED") or end_point is None:
return None
direction = np.array(end_point, dtype=float) - np.array(cutpoint, dtype=float)
norm = float(np.linalg.norm(direction))
if norm < 1e-6:
return None
direction /= norm
for step in range(1, int(_cfg("PICK_DEPTH_LINE_FALLBACK_STEPS")) + 1):
offset = direction * float(_cfg("PICK_DEPTH_LINE_FALLBACK_STEP_PX")) * step
sample_x, sample_y = self.clamp_pixel(cutpoint[0] + offset[0], cutpoint[1] + offset[1], frame_shape)
depth = self.get_robust_depth(depth_frame, sample_x, sample_y, frame_shape)
if depth is not None:
print(
"剪切点无有效深度,使用果梗方向邻近深度 "
f"{depth:.3f}m @ ({sample_x}, {sample_y}) 估计剪切点"
)
return depth
return None
def build_detected_tomato( def build_detected_tomato(
self, self,
candidate: TomatoCandidate, candidate: TomatoCandidate,
@ -1329,7 +1313,7 @@ class VisionController:
return None return None
pixel_x, pixel_y = candidate.cutpoint pixel_x, pixel_y = candidate.cutpoint
depth = self.get_robust_depth(depth_frame, pixel_x, pixel_y, frame.shape) depth = self.get_cutpoint_depth(depth_frame, candidate.cutpoint, candidate.end_point, frame.shape)
if depth is None: if depth is None:
print(f"无效深度值 @ ({pixel_x}, {pixel_y})") print(f"无效深度值 @ ({pixel_x}, {pixel_y})")
return None return None

444
main.py
View File

@ -8,7 +8,6 @@ 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
@ -34,40 +33,20 @@ 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), ("dwCursorPosition", wintypes._COORD),
("dwCursorPosition", wintypes._COORD), ("wAttributes", wintypes.WORD),
("wAttributes", wintypes.WORD), ("srWindow", wintypes.SMALL_RECT),
("srWindow", wintypes.SMALL_RECT), ("dwMaximumWindowSize", wintypes._COORD)
("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_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:
"""番茄采摘系统的主界面类。""" """番茄采摘系统的主界面类。"""
@ -84,39 +63,31 @@ 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 = get_runtime_resource_dir() self.base_dir = Path(__file__).resolve().parent
self.app_dir = get_runtime_app_dir() self.settings_file = self.base_dir / "ui_settings.json"
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": "#FFF4EC", "bg": "#F3F7FB",
"bg_deep": "#F7E0D0", "panel": "#FFFFFF",
"panel": "#FFFDFB", "panel_soft": "#F8FBFD",
"panel_soft": "#FFF2E8", "border": "#D8E3EC",
"panel_alt": "#FBE4D7", "text": "#16324A",
"border": "#E8C9B8", "muted": "#6B7C93",
"text": "#452315", "accent": "#0F766E",
"muted": "#8A5E50", "accent_soft": "#D7F3EE",
"accent": "#D84A32", "danger": "#B45309",
"accent_deep": "#A92F22", "danger_soft": "#FDE7D7",
"accent_soft": "#FFD9CF", "info": "#0F4C81",
"accent_gold": "#F4B860", "info_soft": "#DCEBFA",
"leaf": "#3F8C53", "terminal_bg": "#0F172A",
"leaf_soft": "#D8F0D8", "terminal_fg": "#DCFCE7",
"danger": "#A63E2A", "terminal_muted": "#93C5FD",
"danger_soft": "#FFE0D6", "terminal_err": "#FCA5A5",
"info": "#8F3B2E", "metric_1": "#E6FFFB",
"info_soft": "#FFE7D9", "metric_2": "#EEF2FF",
"terminal_bg": "#2A1410", "metric_3": "#FFF7ED",
"terminal_fg": "#FFE8C9", "metric_4": "#F0FDF4",
"terminal_muted": "#FFB66E",
"terminal_err": "#FF9B8F",
"metric_1": "#FFE4D8",
"metric_2": "#FFF1CF",
"metric_3": "#E7F6DF",
"metric_4": "#FDE6EF",
"shadow": "#EFD6C7",
} }
# 设置中文字体 # 设置中文字体
@ -145,13 +116,6 @@ 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()
@ -164,7 +128,6 @@ 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)
@ -175,7 +138,7 @@ class TomatoHarvestingUI:
# 设置主窗口背景 # 设置主窗口背景
self.set_background_image() self.set_background_image()
main_frame = ttk.Frame(self.root, padding=(18, 16, 18, 18), style="App.TFrame") main_frame = ttk.Frame(self.root, padding=(16, 12, 16, 16), 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(
@ -185,26 +148,16 @@ class TomatoHarvestingUI:
highlightthickness=1, highlightthickness=1,
highlightbackground=self.colors["border"] highlightbackground=self.colors["border"]
) )
header_card.pack(fill=tk.X, pady=(0, 12)) header_card.pack(fill=tk.X, pady=(0, 10))
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=20, pady=(16, 10)) header_top.pack(fill=tk.X, padx=18, pady=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_deep"], fg=self.colors["accent"],
padx=16, padx=14,
pady=6 pady=6
) )
self.status_badge.pack(side=tk.RIGHT) self.status_badge.pack(side=tk.RIGHT)
@ -212,7 +165,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"]
@ -220,27 +173,12 @@ 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)
@ -417,7 +355,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="#FFF7F2", fg=self.colors["text"], insertbackground=self.colors["text"]) self.log_text.config(state=tk.DISABLED, bg="#F0F0F0", fg="#333333", insertbackground="black")
# 相机画面区域(下方) # 相机画面区域(下方)
camera_card = ttk.LabelFrame(center_frame, text="相机画面", padding="8", style="Card.TLabelframe") camera_card = ttk.LabelFrame(center_frame, text="相机画面", padding="8", style="Card.TLabelframe")
@ -446,9 +384,9 @@ class TomatoHarvestingUI:
bd=0, bd=0,
relief="flat", relief="flat",
highlightthickness=0, highlightthickness=0,
bg=self.colors["terminal_bg"], bg="#111111",
fg=self.colors["terminal_fg"], fg="#D8F3DC",
insertbackground=self.colors["terminal_fg"] insertbackground="#D8F3DC"
) )
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)
@ -462,28 +400,23 @@ 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": primary_family, "size": 10}, "default": {"family": "微软雅黑", "size": 9},
"title": {"family": title_family, "size": 20, "weight": "bold"}, "title": {"family": "微软雅黑", "size": 18, "weight": "bold"},
"subtitle": {"family": primary_family, "size": 10}, "subtitle": {"family": "微软雅黑", "size": 10},
"section": {"family": primary_family, "size": 10, "weight": "bold"}, "section": {"family": "微软雅黑", "size": 9, "weight": "bold"},
"button": {"family": primary_family, "size": 10, "weight": "bold"}, "button": {"family": "微软雅黑", "size": 9, "weight": "bold"},
"button_normal": {"family": primary_family, "size": 10}, "button_normal": {"family": "微软雅黑", "size": 9},
"mono": {"family": mono_family, "size": 9}, "mono": {"family": "微软雅黑", "size": 9},
"chip": {"family": primary_family, "size": 9, "weight": "bold"},
} }
self.font_min_sizes = { self.font_min_sizes = {
"default": 8, "default": 7,
"title": 15, "title": 14,
"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():
@ -496,14 +429,6 @@ 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()
@ -517,47 +442,33 @@ 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=7, padding=5,
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=4, padding=3,
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=(12, 8), padding=(10, 6),
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"],
@ -576,12 +487,12 @@ class TomatoHarvestingUI:
background=self.colors["panel"], background=self.colors["panel"],
borderwidth=1, borderwidth=1,
relief="solid", relief="solid",
padding=8 padding=6
) )
style.configure( style.configure(
"SidebarCard.TLabelframe.Label", "SidebarCard.TLabelframe.Label",
background=self.colors["panel"], background=self.colors["panel"],
foreground=self.colors["accent_deep"], foreground=self.colors["text"],
font=self.fonts["section"] font=self.fonts["section"]
) )
style.configure( style.configure(
@ -589,33 +500,33 @@ class TomatoHarvestingUI:
background=self.colors["panel"], background=self.colors["panel"],
borderwidth=1, borderwidth=1,
relief="solid", relief="solid",
padding=8 padding=6
) )
style.configure( style.configure(
"Card.TLabelframe.Label", "Card.TLabelframe.Label",
background=self.colors["panel"], background=self.colors["panel"],
foreground=self.colors["accent_deep"], foreground=self.colors["text"],
font=self.fonts["section"] font=self.fonts["section"]
) )
style.configure( style.configure(
"TerminalCard.TLabelframe", "TerminalCard.TLabelframe",
background=self.colors["panel_alt"], background=self.colors["panel"],
borderwidth=1, borderwidth=1,
relief="solid", relief="solid",
padding=8 padding=6
) )
style.configure( style.configure(
"TerminalCard.TLabelframe.Label", "TerminalCard.TLabelframe.Label",
background=self.colors["panel_alt"], background=self.colors["panel"],
foreground=self.colors["accent_deep"], foreground=self.colors["text"],
font=self.fonts["section"] font=self.fonts["section"]
) )
style.configure("Primary.TButton", background=self.colors["accent"], foreground="#FFF8F4", font=self.fonts["button"]) style.configure("Primary.TButton", background=self.colors["accent"], foreground="#FFFFFF", font=self.fonts["button"])
style.map("Primary.TButton", background=[("active", self.colors["accent_deep"]), ("disabled", "#F1B5AA")], foreground=[("disabled", "#FFF6F2")]) style.map("Primary.TButton", background=[("active", "#0B5E58"), ("disabled", "#A8D7D2")], foreground=[("disabled", "#F7FAFC")])
style.configure("Danger.TButton", background="#B84531", foreground="#FFF8F4", font=self.fonts["button"]) style.configure("Danger.TButton", background="#C2410C", foreground="#FFFFFF", font=self.fonts["button"])
style.map("Danger.TButton", background=[("active", "#8D3022"), ("disabled", "#EDB9AE")], foreground=[("disabled", "#FEF2F2")]) style.map("Danger.TButton", background=[("active", "#9A3412"), ("disabled", "#F3B99A")], 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", self.colors["accent_gold"]), ("disabled", "#F5E6DA")], foreground=[("active", self.colors["text"]), ("disabled", self.colors["muted"])]) style.map("Secondary.TButton", background=[("active", "#C8DFF7"), ("disabled", "#E7EEF5")])
def on_root_resize(self, event): def on_root_resize(self, event):
"""窗口尺寸变化时,延迟刷新字体缩放。""" """窗口尺寸变化时,延迟刷新字体缩放。"""
@ -645,41 +556,6 @@ 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 = {
@ -719,168 +595,12 @@ 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,
] ]
@ -888,20 +608,11 @@ class TomatoHarvestingUI:
"""解析绝对路径或相对项目根目录的路径。""" """解析绝对路径或相对项目根目录的路径。"""
if path_value: if path_value:
candidate = Path(path_value).expanduser() candidate = Path(path_value).expanduser()
if candidate.is_absolute(): if not candidate.is_absolute():
candidate = candidate.resolve() candidate = self.base_dir / candidate
if candidate.exists(): candidate = candidate.resolve()
return candidate if candidate.exists():
else: return candidate
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():
@ -1019,8 +730,7 @@ 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")
default_model_path = self.resolve_path("models/best.pt", fallback_name="best.pt") self.yolo_model_path.insert(0, "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))
@ -1047,9 +757,7 @@ 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)
saved_model_path = settings.get("YOLO_MODEL_PATH", "models/best.pt") self.yolo_model_path.insert(0, str(settings.get("YOLO_MODEL_PATH", "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")

View File

@ -1,14 +0,0 @@
# TomatoPick runtime dependencies for Windows packaging.
# If you need CUDA acceleration, install a matching torch/torchvision/torchaudio
# build first, then run: pip install -r requirements.txt
# tkinter is not listed here because it is bundled with the standard Windows
# CPython installer.
Pillow==11.2.1
opencv-python==4.10.0.84
numpy==2.0.0
pyrealsense2==2.55.1.6486
pyaubo_agvc_sdk==0.2.0
pyaubo_sdk==0.24.1
ultralytics==8.3.112
pyinstaller>=6,<7