From 47c08a64664ef63870824ff670406442b2096f1a Mon Sep 17 00:00:00 2001 From: Brunsmeier <2970937094@qq.com> Date: Tue, 14 Jul 2026 15:12:08 +0800 Subject: [PATCH] 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. --- README.md | 60 ++----- TomatoPick.spec | 91 ---------- control.py | 33 +++- control_core.py | 142 +++++++-------- main.py | 444 ++++++++--------------------------------------- requirements.txt | 14 -- 6 files changed, 180 insertions(+), 604 deletions(-) delete mode 100644 TomatoPick.spec delete mode 100644 requirements.txt diff --git a/README.md b/README.md index 99ea9fe..c22a4a7 100644 --- a/README.md +++ b/README.md @@ -113,56 +113,6 @@ 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。 @@ -234,6 +184,16 @@ best.pt 模型类别、关键点顺序和训练定义需要与上述逻辑一致。 +### 真实果梗的适配 + +采摘流程仍保持原来的两关键点和单次剪刀动作:第 0 点用于定位采摘点,第 1 点只用于计算末端角度。为适应真实串番茄,控制参数新增了以下处理: + +- 剪切点深度无效时,先扩大邻域取中位深度;仍失败时沿第 0 点到第 1 点的果梗方向取有限次数的邻近深度,并将该深度用于剪切点反投影。 +- 关键点连线按无方向直线处理,方向点在剪切点的上方、下方或侧方均可;默认最大末端旋转已提高到 $70^\circ$。 +- `CUT_PULL_ASSIST_ENABLED` 默认关闭。没有“已剪断”传感反馈时,程序不能可靠判断剪刀是否剪断了老梗。确认撤离方向、碰撞空间和末端承载能力后,才可开启该项,以在原有撤离前增加一个小的 Y 向拉拽动作。 + +首次调试建议保持辅助拉拽关闭,先以低速确认深度回退输出、关键点角度和机械臂姿态均正确。 + ## 运行前检查 完整采摘流程会控制真实硬件。启动前请确认: diff --git a/TomatoPick.spec b/TomatoPick.spec deleted file mode 100644 index 7d59ef8..0000000 --- a/TomatoPick.spec +++ /dev/null @@ -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", -) \ No newline at end of file diff --git a/control.py b/control.py index 1c4a95a..b910746 100644 --- a/control.py +++ b/control.py @@ -27,10 +27,22 @@ AGV_RUN_DISTANCE = 5.0 TOTAL_DURATION = 300 AGV_STOP_TIMEOUT = 10 AGV_PICK_SETTLE_DELAY = 1.5 -YOLO_MODEL_PATH = "models/best.pt" +YOLO_MODEL_PATH = "best.pt" YOLO_DETECT_CONF = 0.5 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 是机械臂的初始/复位关节位。 @@ -45,7 +57,13 @@ PICKUP_X_OFFSET = 0.0 PICKUP_Y_OFFSET = -0.013 PICK_ANGLE_ORIENTATION_INDEX = 4 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。 place_positions = [[-0.6203, 0.026, 0.1078, 2.9923, 0.0366, -1.4157]] @@ -78,6 +96,15 @@ _CONFIG_NAMES = ( "YOLO_MODEL_PATH", "YOLO_DETECT_CONF", "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", "APPROACH_Y_OFFSET", "LIFT_Z_OFFSET", @@ -86,6 +113,8 @@ _CONFIG_NAMES = ( "PICK_ANGLE_ORIENTATION_INDEX", "PICK_ANGLE_SIGN", "MAX_PICK_ANGLE_DEG", + "CUT_PULL_ASSIST_ENABLED", + "CUT_PULL_ASSIST_Y_OFFSET", "GRIPPER_CLOSE_IO", "GRIPPER_OPEN_IO", "GRIPPER_ACTION_DELAY", diff --git a/control_core.py b/control_core.py index 97f4452..19392c7 100644 --- a/control_core.py +++ b/control_core.py @@ -6,7 +6,6 @@ import os import sys import threading import time -from pathlib import Path import cv2 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_CUTPOINT_MAX_REL_Y = 0.70 # 采摘点在 bbox 内的最大相对高度,防止误取下方点。 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( # [ @@ -106,61 +98,6 @@ 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) @@ -804,7 +741,21 @@ class RobotArmController: 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: self.return_home() return False @@ -854,9 +805,7 @@ def init_camera() -> Tuple[Any, Any, Any]: def init_tomato_detector(model_path: Optional[str] = None) -> Any: """初始化 YOLO 模型。""" - 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 + use_path = model_path or _cfg("YOLO_MODEL_PATH") or "best.pt" print(f"加载 YOLO 模型 | 路径: {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: return 0.0 + # 果梗是一条无方向直线;endpoint 在剪切点的任一侧都应表示同一倾角。 + # 归一化后,横向或斜向果梗不会因关键点标注顺序而得到 180°旋转。 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"))) 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)") 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 def extract_candidate_keypoints( @@ -1271,7 +1222,7 @@ class VisionController: for sample_y in range(y_start, y_end + 1): for sample_x in range(x_start, x_end + 1): 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) return samples @@ -1283,25 +1234,27 @@ class VisionController: frame_shape: Tuple[int, int, int], ) -> Optional[float]: 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) - if len(samples) < PICK_DEPTH_MIN_VALID_PIXELS: + if len(samples) < _cfg("PICK_DEPTH_MIN_VALID_PIXELS"): continue sample_array = np.array(samples, dtype=float) median_depth = float(np.median(sample_array)) - stable_samples = sample_array[np.abs(sample_array - median_depth) <= PICK_DEPTH_MEDIAN_TOLERANCE_M] - if len(stable_samples) >= PICK_DEPTH_MIN_VALID_PIXELS: + stable_samples = sample_array[ + 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)) else: 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( "采摘点中心深度无效,使用邻域稳健深度 " 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( "采摘点中心深度跳变,使用邻域稳健深度 " f"{depth:.3f}m 替代 {center_depth:.3f}m @ ({pixel_x}, {pixel_y})" @@ -1314,6 +1267,37 @@ class VisionController: ) 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( self, candidate: TomatoCandidate, @@ -1329,7 +1313,7 @@ class VisionController: return None 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: print(f"无效深度值 @ ({pixel_x}, {pixel_y})") return None diff --git a/main.py b/main.py index 89ee573..1f86352 100644 --- a/main.py +++ b/main.py @@ -8,7 +8,6 @@ import os import sys import json import io -import math import ctypes from ctypes import wintypes from pathlib import Path @@ -34,40 +33,20 @@ import contextlib # Windows 控制台缓冲区结构体。 # 当程序在 Windows 上运行时,会用它读取后台线程输出的控制台内容, # 再把这些内容转发到 Tkinter 的日志面板中。 -if sys.platform == "win32": - class CONSOLE_SCREEN_BUFFER_INFO(ctypes.Structure): - _fields_ = [ - ("dwSize", wintypes._COORD), - ("dwCursorPosition", wintypes._COORD), - ("wAttributes", wintypes.WORD), - ("srWindow", wintypes.SMALL_RECT), - ("dwMaximumWindowSize", wintypes._COORD) - ] - - STD_OUTPUT_HANDLE = -11 - kernel32 = ctypes.WinDLL('kernel32', use_last_error=True) -else: - CONSOLE_SCREEN_BUFFER_INFO = None - STD_OUTPUT_HANDLE = None - kernel32 = None +class CONSOLE_SCREEN_BUFFER_INFO(ctypes.Structure): + _fields_ = [ + ("dwSize", wintypes._COORD), + ("dwCursorPosition", wintypes._COORD), + ("wAttributes", wintypes.WORD), + ("srWindow", wintypes.SMALL_RECT), + ("dwMaximumWindowSize", wintypes._COORD) + ] +STD_OUTPUT_HANDLE = -11 +kernel32 = ctypes.WinDLL('kernel32', use_last_error=True) 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: """番茄采摘系统的主界面类。""" @@ -84,39 +63,31 @@ 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 = get_runtime_resource_dir() - self.app_dir = get_runtime_app_dir() - self.settings_file = self.app_dir / "ui_settings.json" + self.base_dir = Path(__file__).resolve().parent + self.settings_file = self.base_dir / "ui_settings.json" self._font_resize_job = None self._is_applying_font_scale = False self.colors = { - "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", + "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", } # 设置中文字体 @@ -145,13 +116,6 @@ 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() @@ -164,7 +128,6 @@ 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) @@ -175,7 +138,7 @@ class TomatoHarvestingUI: # 设置主窗口背景 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) header_card = tk.Frame( @@ -185,26 +148,16 @@ class TomatoHarvestingUI: highlightthickness=1, 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.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)) + header_top.pack(fill=tk.X, padx=18, pady=10) self.status_badge = tk.Label( header_top, text="系统就绪", font=self.fonts["button"], bg=self.colors["accent_soft"], - fg=self.colors["accent_deep"], - padx=16, + fg=self.colors["accent"], + padx=14, pady=6 ) self.status_badge.pack(side=tk.RIGHT) @@ -212,7 +165,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"] @@ -220,27 +173,12 @@ 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) @@ -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.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") @@ -446,9 +384,9 @@ class TomatoHarvestingUI: bd=0, relief="flat", highlightthickness=0, - bg=self.colors["terminal_bg"], - fg=self.colors["terminal_fg"], - insertbackground=self.colors["terminal_fg"] + bg="#111111", + fg="#D8F3DC", + insertbackground="#D8F3DC" ) self.terminal_text.pack(fill=tk.BOTH, expand=True, pady=5) self.terminal_text.config(state=tk.DISABLED) @@ -462,28 +400,23 @@ 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": 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"}, + "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}, } self.font_min_sizes = { - "default": 8, - "title": 15, + "default": 7, + "title": 14, "subtitle": 8, "section": 8, "button": 8, "button_normal": 8, "mono": 8, - "chip": 8, } self.fonts = {} for name, spec in self.font_specs.items(): @@ -496,14 +429,6 @@ class TomatoHarvestingUI: self.root.option_add("*Font", self.fonts["default"]) self.root.bind("", self.on_root_resize) - def pick_font_family(self, candidates): - """在当前系统中优先选择更适合中文界面的字体。""" - available = set(tkfont.families(self.root)) - for family in candidates: - if family in available: - return family - return "TkDefaultFont" - def setup_styles(self): """集中配置 ttk 控件样式,避免样式定义散落在各个方法中。""" style = ttk.Style() @@ -517,47 +442,33 @@ 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=7, + padding=5, font=self.fonts["default"], relief="flat", - borderwidth=1, - insertcolor=self.colors["accent_deep"] + borderwidth=1 ) - 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=4, + padding=3, font=self.fonts["default"] ) style.configure( "TButton", font=self.fonts["button_normal"], - padding=(12, 8), + padding=(10, 6), 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"], @@ -576,12 +487,12 @@ class TomatoHarvestingUI: background=self.colors["panel"], borderwidth=1, relief="solid", - padding=8 + padding=6 ) style.configure( "SidebarCard.TLabelframe.Label", background=self.colors["panel"], - foreground=self.colors["accent_deep"], + foreground=self.colors["text"], font=self.fonts["section"] ) style.configure( @@ -589,33 +500,33 @@ class TomatoHarvestingUI: background=self.colors["panel"], borderwidth=1, relief="solid", - padding=8 + padding=6 ) style.configure( "Card.TLabelframe.Label", background=self.colors["panel"], - foreground=self.colors["accent_deep"], + foreground=self.colors["text"], font=self.fonts["section"] ) style.configure( "TerminalCard.TLabelframe", - background=self.colors["panel_alt"], + background=self.colors["panel"], borderwidth=1, relief="solid", - padding=8 + padding=6 ) style.configure( "TerminalCard.TLabelframe.Label", - background=self.colors["panel_alt"], - foreground=self.colors["accent_deep"], + background=self.colors["panel"], + foreground=self.colors["text"], font=self.fonts["section"] ) - 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("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("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): """窗口尺寸变化时,延迟刷新字体缩放。""" @@ -645,41 +556,6 @@ 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 = { @@ -719,168 +595,12 @@ 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, ] @@ -888,20 +608,11 @@ class TomatoHarvestingUI: """解析绝对路径或相对项目根目录的路径。""" if path_value: candidate = Path(path_value).expanduser() - 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 not candidate.is_absolute(): + candidate = self.base_dir / candidate + candidate = candidate.resolve() + if candidate.exists(): + return candidate if fallback_name: for candidate in self.get_resource_candidates(fallback_name): if candidate.exists(): @@ -1019,8 +730,7 @@ class TomatoHarvestingUI: self.agv_speed_stop.insert(0, "0.0") self.total_duration.insert(0, "300") 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, str(default_model_path) if default_model_path else "models/best.pt") + self.yolo_model_path.insert(0, "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)) @@ -1047,9 +757,7 @@ 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) - 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.yolo_model_path.insert(0, str(settings.get("YOLO_MODEL_PATH", "best.pt"))) 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") diff --git a/requirements.txt b/requirements.txt deleted file mode 100644 index e1d01a4..0000000 --- a/requirements.txt +++ /dev/null @@ -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 \ No newline at end of file