From 26527e7775f5cf316acafd9347aba69d60fcddda Mon Sep 17 00:00:00 2001 From: Havoc <2993167370@qq.com> Date: Sun, 18 May 2025 16:59:56 +0800 Subject: [PATCH] =?UTF-8?q?=E2=9C=A8=20=E5=9C=A8=20move=5Fbase=5Fhori=5Fli?= =?UTF-8?q?ne.py=20=E4=B8=AD=E6=96=B0=E5=A2=9E=20follow=5Fleft=5Fside=5Ftr?= =?UTF-8?q?ack=20=E5=87=BD=E6=95=B0=EF=BC=8C=E5=AE=9E=E7=8E=B0=E6=9C=BA?= =?UTF-8?q?=E5=99=A8=E7=8B=97=E6=B2=BF=E5=B7=A6=E4=BE=A7=E9=BB=84=E8=89=B2?= =?UTF-8?q?=E8=BD=A8=E8=BF=B9=E7=BA=BF=E7=A7=BB=E5=8A=A8=E7=9A=84=E6=8E=A7?= =?UTF-8?q?=E5=88=B6=E9=80=BB=E8=BE=91=E3=80=82=E5=90=8C=E6=97=B6=E5=9C=A8?= =?UTF-8?q?=20detect=5Ftrack.py=20=E4=B8=AD=E6=B7=BB=E5=8A=A0=20detect=5Fl?= =?UTF-8?q?eft=5Fside=5Ftrack=20=E5=87=BD=E6=95=B0=EF=BC=8C=E7=94=A8?= =?UTF-8?q?=E4=BA=8E=E6=A3=80=E6=B5=8B=E5=B7=A6=E4=BE=A7=E8=BD=A8=E8=BF=B9?= =?UTF-8?q?=E7=BA=BF=EF=BC=8C=E5=A2=9E=E5=BC=BA=E4=BA=86=E6=9C=BA=E5=99=A8?= =?UTF-8?q?=E7=8B=97=E7=9A=84=E8=BD=A8=E8=BF=B9=E8=B7=9F=E9=9A=8F=E8=83=BD?= =?UTF-8?q?=E5=8A=9B=E5=92=8C=E7=A8=B3=E5=AE=9A=E6=80=A7=E3=80=82=E6=9B=B4?= =?UTF-8?q?=E6=96=B0=E4=BA=86=E7=9B=B8=E5=85=B3=E5=8F=82=E6=95=B0=E5=92=8C?= =?UTF-8?q?=E6=97=A5=E5=BF=97=E8=AE=B0=E5=BD=95=E5=8A=9F=E8=83=BD=EF=BC=8C?= =?UTF-8?q?=E6=8F=90=E5=8D=87=E4=BA=86=E8=B0=83=E8=AF=95=E4=BF=A1=E6=81=AF?= =?UTF-8?q?=E7=9A=84=E5=8F=AF=E8=AF=BB=E6=80=A7=E3=80=82?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- base_move/move_base_hori_line.py | 253 +++++++++++++++++- logs/robot_2025-05-18.log | 25 ++ .../result_image_20250513_162556.png | Bin 0 -> 47555 bytes test/task-path-track/left_track_demo.py | 231 ++++++++++++++++ utils/detect_track.py | 237 ++++++++++++++++ 5 files changed, 745 insertions(+), 1 deletion(-) create mode 100644 logs/robot_2025-05-18.log create mode 100644 res/path/test/left_track_results/result_image_20250513_162556.png create mode 100644 test/task-path-track/left_track_demo.py diff --git a/base_move/move_base_hori_line.py b/base_move/move_base_hori_line.py index c94fc12..c8725c0 100644 --- a/base_move/move_base_hori_line.py +++ b/base_move/move_base_hori_line.py @@ -7,7 +7,7 @@ import sys import os sys.path.append(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) -from utils.detect_track import detect_horizontal_track_edge, detect_dual_track_lines +from utils.detect_track import detect_horizontal_track_edge, detect_dual_track_lines, detect_left_side_track from base_move.turn_degree import turn_degree from base_move.go_straight import go_straight from utils.log_helper import LogHelper, get_logger, section, info, debug, warning, error, success, timing @@ -1123,6 +1123,255 @@ def follow_dual_tracks(ctrl, msg, speed=0.5, max_time=30, target_distance=None, return True +def follow_left_side_track(ctrl, msg, target_distance=150, speed=0.3, max_time=30, observe=False): + """ + 控制机器狗向左侧移动并靠近左侧的黄色轨迹线 + + 参数: + ctrl: Robot_Ctrl 对象,包含里程计信息 + msg: robot_control_cmd_lcmt 对象,用于发送命令 + target_distance: 目标与左侧线的像素距离,默认为150像素(保持一定间距) + speed: 移动速度(米/秒),默认为0.3米/秒 + max_time: 最大执行时间(秒),默认为30秒 + observe: 是否输出中间状态信息和可视化结果,默认为False + + 返回: + bool: 是否成功达到目标位置 + """ + section("开始左侧轨迹线跟随", "左侧跟踪") + + # 设置移动命令基本参数 + msg.mode = 11 # Locomotion模式 + msg.gait_id = 26 # 自变频步态 + msg.duration = 0 # wait next cmd + msg.step_height = [0.06, 0.06] # 抬腿高度 + + # 记录起始时间 + start_time = time.time() + + # 记录起始位置 + start_position = list(ctrl.odo_msg.xyz) + if observe: + debug(f"起始位置: {start_position}", "位置") + # 在起点放置绿色标记 + if hasattr(ctrl, 'place_marker'): + ctrl.place_marker(start_position[0], start_position[1], start_position[2] if len(start_position) > 2 else 0.0, 'green', observe=True) + + # PID控制参数 - 侧向移动的PID参数 + kp_side = 0.002 # 比例系数 + ki_side = 0.0001 # 积分系数 + kd_side = 0.0005 # 微分系数 + + # 记录目标到达状态和稳定计数 + target_reached = False + stable_count = 0 + required_stable_count = 10 # 需要连续稳定的检测次数 + + # PID控制变量 + previous_error = 0 + integral = 0 + + # 最大侧向速度限制 + max_side_velocity = 0.3 # m/s + + # 检测成功计数器 + detection_success_count = 0 + detection_total_count = 0 + + # 保存上一次有效的检测结果,用于检测失败时的平滑过渡 + last_valid_track_info = None + + # 滤波队列 + filter_size = 5 + distance_queue = [] + + # 开始跟踪循环 + while time.time() - start_time < max_time: + # 获取当前图像 + image = ctrl.image_processor.get_current_image() + + # 检测左侧轨迹线 + detection_total_count += 1 + track_info, tracking_point = detect_left_side_track(image, observe=observe, delay=500 if observe else 0, save_log=True) + + if track_info is not None: + detection_success_count += 1 + + # 保存有效的轨迹信息 + last_valid_track_info = track_info + + # 获取当前与左侧线的距离 + current_distance = track_info["distance_to_left"] + + # 添加到滤波队列 + distance_queue.append(current_distance) + if len(distance_queue) > filter_size: + distance_queue.pop(0) + + # 计算滤波后的距离值 (去除最大和最小值后的平均) + if len(distance_queue) >= 3: + filtered_distances = sorted(distance_queue)[1:-1] if len(distance_queue) > 2 else distance_queue + filtered_distance = sum(filtered_distances) / len(filtered_distances) + else: + filtered_distance = current_distance + + if observe and time.time() % 0.5 < 0.02: + debug(f"原始左侧距离: {current_distance:.1f}px, 滤波后: {filtered_distance:.1f}px, 目标: {target_distance}px", "距离") + + # 计算误差 - 正误差表示需要向左移动,负误差表示需要向右移动 + error = target_distance - filtered_distance + + # 检查是否达到目标位置 + if abs(error) < 20: # 误差小于20像素视为达到目标 + stable_count += 1 + if stable_count >= required_stable_count: + if not target_reached: + target_reached = True + if observe: + success(f"已达到目标位置,误差: {abs(error):.1f}px", "成功") + else: + stable_count = 0 + target_reached = False + + # 比例项 + p_control = kp_side * error + + # 积分项 (添加积分限制以防止积分饱和) + integral += error + integral = max(-500, min(500, integral)) # 限制积分范围 + i_control = ki_side * integral + + # 微分项 + derivative = error - previous_error + d_control = kd_side * derivative + previous_error = error + + # 计算侧向速度 + side_velocity = p_control + i_control + d_control + + # 限制侧向速度范围 + side_velocity = max(-max_side_velocity, min(max_side_velocity, side_velocity)) + + # 根据目标状态调整速度 + forward_speed = speed + if target_reached: + # 如果已达到目标,降低侧向速度以减少振荡 + side_velocity *= 0.5 + # 降低前进速度让侧向移动更平稳 + forward_speed *= 0.7 + + if observe and time.time() % 0.5 < 0.02: + debug(f"误差: {error:.1f}px, P: {p_control:.4f}, I: {i_control:.4f}, D: {d_control:.4f}", "控制") + debug(f"控制指令: 前进={forward_speed:.2f}m/s, 侧向={side_velocity:.2f}m/s", "速度") + + # 设置速度命令 - [前进速度, 侧向速度, 角速度] + # 侧向速度为正表示向左移动,为负表示向右移动 + msg.vel_des = [forward_speed, side_velocity, 0] + + # 使用轨迹线斜率进行小角度调整,确保机器人方向与轨迹线平行 + # 斜率大于0表示向右倾斜,需要顺时针旋转(负角速度) + # 斜率小于0表示向左倾斜,需要逆时针旋转(正角速度) + if not track_info["is_vertical"]: # 对于非垂直线,进行角度校正 + slope = track_info["slope"] + # 计算旋转角度 - 垂直线的ideal_angle应该是0 + angle_correction = -math.atan(1/slope) if abs(slope) > 0.01 else 0 + # 将角度校正应用为小的角速度 + angular_velocity = angle_correction * 0.2 # 角速度控制系数 + # 限制角速度范围 + angular_velocity = max(-0.2, min(0.2, angular_velocity)) + + if abs(angular_velocity) > 0.02: # 只在需要明显校正时应用 + msg.vel_des[2] = angular_velocity + if observe and time.time() % 0.5 < 0.02: + debug(f"应用角度校正,斜率: {slope:.2f}, 角速度: {angular_velocity:.3f}", "校正") + + else: + warning("未检测到左侧轨迹线", "警告") + + # 如果之前有有效检测结果,使用上一次的控制值但降低强度 + if last_valid_track_info is not None and len(distance_queue) > 0: + # 使用上一次的距离,但降低侧向速度 + reduced_speed = speed * 0.5 # 降低前进速度 + + # 如果上次距离已经接近目标,维持当前位置 + last_error = target_distance - distance_queue[-1] + if abs(last_error) < 50: # 如果接近目标距离 + side_velocity = 0 # 停止侧向移动 + if observe: + info("接近目标位置,保持当前侧向位置", "保持") + else: + # 向目标方向缓慢移动 + side_velocity = 0.05 * (1 if last_error > 0 else -1) + if observe: + info(f"距离目标较远,缓慢向{'左' if side_velocity > 0 else '右'}移动", "调整") + + msg.vel_des = [reduced_speed, side_velocity, 0] + if observe: + warning(f"使用降低强度的控制: 前进={reduced_speed:.2f}m/s, 侧向={side_velocity:.2f}m/s", "恢复") + else: + # 如果从未检测到轨迹线,降低速度直接向前 + reduced_speed = speed * 0.3 + msg.vel_des = [reduced_speed, 0, 0] + if observe: + warning(f"无法检测到左侧轨迹线,降低速度前进: {reduced_speed:.2f}m/s", "警告") + + # 发送命令 + msg.life_count += 1 + ctrl.Send_cmd(msg) + + # 短暂延时 + time.sleep(0.05) + + # 计算已移动距离(仅用于记录) + current_position = ctrl.odo_msg.xyz + dx = current_position[0] - start_position[0] + dy = current_position[1] - start_position[1] + distance_moved = math.sqrt(dx*dx + dy*dy) + + if observe and time.time() % 2.0 < 0.02: # 每2秒左右打印一次 + info(f"已移动: {distance_moved:.3f}米, 已用时间: {time.time() - start_time:.1f}秒", "移动") + # 显示检测成功率 + if detection_total_count > 0: + detection_rate = (detection_success_count / detection_total_count) * 100 + info(f"左侧轨迹检测成功率: {detection_rate:.1f}% ({detection_success_count}/{detection_total_count})", "统计") + + # 平滑停止 + if observe: + info("开始平滑停止", "停止") + + # 先降低速度再停止,实现平滑停止 + slowdown_steps = 5 + for i in range(slowdown_steps, 0, -1): + slowdown_factor = i / slowdown_steps + msg.vel_des = [speed * slowdown_factor, 0, 0] + msg.life_count += 1 + ctrl.Send_cmd(msg) + time.sleep(0.1) + + # 最后完全停止 + ctrl.base_msg.stop() + + # 计算最终移动距离 + final_position = ctrl.odo_msg.xyz + dx = final_position[0] - start_position[0] + dy = final_position[1] - start_position[1] + final_distance = math.sqrt(dx*dx + dy*dy) + + if observe: + # 在终点放置红色标记 + end_position = list(final_position) + if hasattr(ctrl, 'place_marker'): + ctrl.place_marker(end_position[0], end_position[1], end_position[2] if len(end_position) > 2 else 0.0, 'red', observe=True) + + success(f"左侧轨迹跟随完成,总移动距离: {final_distance:.3f}米", "完成") + + # 显示检测成功率 + if detection_total_count > 0: + detection_rate = (detection_success_count / detection_total_count) * 100 + info(f"左侧轨迹检测成功率: {detection_rate:.1f}% ({detection_success_count}/{detection_total_count})", "统计") + + return target_reached + # 用法示例 if __name__ == "__main__": move_to_hori_line(None, None, observe=True) @@ -1132,4 +1381,6 @@ if __name__ == "__main__": arc_turn_around_hori_line(None, None, angle_deg=-90, observe=True) # 双轨道跟随 follow_dual_tracks(None, None, observe=True) + # 左侧轨迹跟随 + follow_left_side_track(None, None, observe=True) \ No newline at end of file diff --git a/logs/robot_2025-05-18.log b/logs/robot_2025-05-18.log new file mode 100644 index 0000000..b008187 --- /dev/null +++ b/logs/robot_2025-05-18.log @@ -0,0 +1,25 @@ +2025-05-18 16:58:17 | ERROR | utils.log_helper - ❌ 左侧区域未检测到垂直线 +2025-05-18 16:58:45 | DEBUG | utils.log_helper - 🐞 步骤1: 原始图像已加载 +2025-05-18 16:58:46 | DEBUG | utils.log_helper - 🐞 步骤2: 创建黄色掩码 +2025-05-18 16:58:47 | DEBUG | utils.log_helper - 🐞 步骤3: 左侧区域掩码 +2025-05-18 16:58:48 | DEBUG | utils.log_helper - 🐞 步骤4: 边缘检测 +2025-05-18 16:58:49 | DEBUG | utils.log_helper - 🐞 步骤5: 检测到 17 条直线 +2025-05-18 16:58:50 | ERROR | utils.log_helper - ❌ 左侧区域未检测到垂直线 +2025-05-18 16:59:14 | DEBUG | utils.log_helper - 🐞 步骤1: 原始图像已加载 +2025-05-18 16:59:15 | DEBUG | utils.log_helper - 🐞 步骤2: 创建黄色掩码 +2025-05-18 16:59:16 | DEBUG | utils.log_helper - 🐞 步骤3: 左侧区域掩码 +2025-05-18 16:59:17 | DEBUG | utils.log_helper - 🐞 步骤4: 边缘检测 +2025-05-18 16:59:18 | DEBUG | utils.log_helper - 🐞 步骤5: 检测到 26 条直线 +2025-05-18 16:59:19 | DEBUG | utils.log_helper - 🐞 步骤6: 左侧区域找到 2 条垂直线 +2025-05-18 16:59:20 | DEBUG | utils.log_helper - 🐞 步骤7: 左侧最佳跟踪线和点 +2025-05-18 16:59:21 | INFO | utils.log_helper - ℹ️ 保存左侧轨迹线检测结果图像到: logs/image/left_track_20250518_165921_631983.jpg +2025-05-18 16:59:21 | INFO | utils.log_helper - ℹ️ 左侧轨迹线检测结果: {'timestamp': '20250518_165921_631983', 'tracking_point': (95, 1077), 'ground_intersection': (91, 1080), 'distance_to_left': 216.5, 'slope': -0.7530864197530864, 'line_mid_x': 216.5} +2025-05-18 16:59:34 | DEBUG | utils.log_helper - 🐞 步骤1: 原始图像已加载 +2025-05-18 16:59:35 | DEBUG | utils.log_helper - 🐞 步骤2: 创建黄色掩码 +2025-05-18 16:59:36 | DEBUG | utils.log_helper - 🐞 步骤3: 左侧区域掩码 +2025-05-18 16:59:37 | DEBUG | utils.log_helper - 🐞 步骤4: 边缘检测 +2025-05-18 16:59:38 | DEBUG | utils.log_helper - 🐞 步骤5: 检测到 26 条直线 +2025-05-18 16:59:39 | DEBUG | utils.log_helper - 🐞 步骤6: 左侧区域找到 2 条垂直线 +2025-05-18 16:59:40 | DEBUG | utils.log_helper - 🐞 步骤7: 左侧最佳跟踪线和点 +2025-05-18 16:59:41 | INFO | utils.log_helper - ℹ️ 保存左侧轨迹线检测结果图像到: logs/image/left_track_20250518_165941_364168.jpg +2025-05-18 16:59:41 | INFO | utils.log_helper - ℹ️ 左侧轨迹线检测结果: {'timestamp': '20250518_165941_364168', 'tracking_point': (95, 1077), 'ground_intersection': (91, 1080), 'distance_to_left': 216.5, 'slope': -0.7530864197530864, 'line_mid_x': 216.5} diff --git a/res/path/test/left_track_results/result_image_20250513_162556.png b/res/path/test/left_track_results/result_image_20250513_162556.png new file mode 100644 index 0000000000000000000000000000000000000000..bb43db2d9eb27492de0f1ac839b1f09fd2276b6d GIT binary patch literal 47555 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 Bj6wha literal 0 HcmV?d00001 diff --git a/test/task-path-track/left_track_demo.py b/test/task-path-track/left_track_demo.py new file mode 100644 index 0000000..dacc369 --- /dev/null +++ b/test/task-path-track/left_track_demo.py @@ -0,0 +1,231 @@ +import cv2 +import os +import sys +import time +import argparse + +# 添加父目录到系统路径 +current_dir = os.path.dirname(os.path.abspath(__file__)) +project_root = os.path.dirname(os.path.dirname(current_dir)) +sys.path.append(project_root) + +from utils.detect_track import detect_left_side_track + +def process_image(image_path, save_dir=None, show_steps=False): + """处理单张图像""" + print(f"处理图像: {image_path}") + + # 检测左侧轨迹线 + start_time = time.time() + track_info, tracking_point = detect_left_side_track(image_path, observe=show_steps, save_log=True) + processing_time = time.time() - start_time + + # 输出结果 + if track_info is not None and tracking_point is not None: + print(f"处理时间: {processing_time:.3f}秒") + print(f"最佳跟踪点: ({tracking_point[0]}, {tracking_point[1]})") + print(f"距左边界: {track_info['distance_to_left']:.1f}像素") + print(f"线段斜率: {track_info['slope']:.4f}") + print(f"是否垂直: {track_info['is_vertical']}") + print(f"线段中点: ({track_info['mid_x']:.1f}, {track_info['mid_y']:.1f})") + print(f"地面交点: ({track_info['ground_intersection'][0]}, {track_info['ground_intersection'][1]})") + + # 提取线段坐标 + x1, y1, x2, y2 = track_info['line'] + print(f"线段端点: ({x1}, {y1}) - ({x2}, {y2})") + print("-" * 30) + + # 如果指定了保存目录,加载原始图像并绘制检测结果 + if save_dir: + if not os.path.exists(save_dir): + os.makedirs(save_dir) + + # 构建输出文件路径 + base_name = os.path.basename(image_path) + out_path = os.path.join(save_dir, f"result_{base_name}") + + # 加载原始图像 + img = cv2.imread(image_path) + if img is not None: + # 绘制检测结果 + height, width = img.shape[:2] + center_x = width // 2 + + # 绘制左侧区域范围 + cv2.rectangle(img, (0, 0), (center_x, height), (255, 0, 0), 2) + + # 绘制检测到的线段 + cv2.line(img, (x1, y1), (x2, y2), (0, 255, 0), 2) + + # 绘制跟踪点 + cv2.circle(img, tracking_point, 8, (0, 0, 255), -1) + + # 绘制地面交点 + cv2.circle(img, track_info['ground_intersection'], 8, (255, 0, 255), -1) + + # 添加文本信息 + cv2.putText(img, f"斜率: {track_info['slope']:.2f}", (10, 30), + cv2.FONT_HERSHEY_SIMPLEX, 0.8, (0, 255, 0), 2) + cv2.putText(img, f"距左边界: {track_info['distance_to_left']:.1f}px", (10, 70), + cv2.FONT_HERSHEY_SIMPLEX, 0.8, (0, 255, 0), 2) + + # 保存结果图像 + cv2.imwrite(out_path, img) + print(f"结果已保存至: {out_path}") + else: + print("未能检测到左侧黄色轨迹线") + + return track_info, tracking_point + +def process_video(video_path, save_dir=None, show_steps=False): + """处理视频""" + print(f"处理视频: {video_path}") + + # 打开视频文件 + cap = cv2.VideoCapture(video_path) + if not cap.isOpened(): + print("错误:无法打开视频文件") + return + + # 获取视频信息 + fps = cap.get(cv2.CAP_PROP_FPS) + width = int(cap.get(cv2.CAP_PROP_FRAME_WIDTH)) + height = int(cap.get(cv2.CAP_PROP_FRAME_HEIGHT)) + frame_count = int(cap.get(cv2.CAP_PROP_FRAME_COUNT)) + + print(f"视频信息: {width}x{height}, {fps}fps, 总帧数: {frame_count}") + + # 如果需要保存结果视频 + video_writer = None + if save_dir: + if not os.path.exists(save_dir): + os.makedirs(save_dir) + + # 构建输出文件路径 + base_name = os.path.basename(video_path) + name, ext = os.path.splitext(base_name) + out_path = os.path.join(save_dir, f"result_{name}{ext}") + + # 创建视频写入器 + fourcc = cv2.VideoWriter_fourcc(*'mp4v') # 可以根据需要修改编码器 + video_writer = cv2.VideoWriter(out_path, fourcc, fps, (width, height)) + + # 帧计数器 + frame_idx = 0 + detect_success_count = 0 + processing_times = [] + + # 处理每一帧 + while True: + ret, frame = cap.read() + if not ret: + break + + frame_idx += 1 + print(f"\r处理帧 {frame_idx}/{frame_count}", end="") + + # 检测左侧轨迹线 + start_time = time.time() + track_info, tracking_point = detect_left_side_track(frame, observe=False, save_log=False) + processing_time = time.time() - start_time + processing_times.append(processing_time) + + # 如果检测成功 + if track_info is not None and tracking_point is not None: + detect_success_count += 1 + + # 提取线段坐标 + x1, y1, x2, y2 = track_info['line'] + + # 在帧上绘制检测结果 + center_x = width // 2 + + # 绘制左侧区域范围 + cv2.rectangle(frame, (0, 0), (center_x, height), (255, 0, 0), 2) + + # 绘制检测到的线段 + cv2.line(frame, (x1, y1), (x2, y2), (0, 255, 0), 2) + + # 绘制跟踪点 + cv2.circle(frame, tracking_point, 8, (0, 0, 255), -1) + + # 绘制地面交点 + cv2.circle(frame, track_info['ground_intersection'], 8, (255, 0, 255), -1) + + # 添加文本信息 + cv2.putText(frame, f"斜率: {track_info['slope']:.2f}", (10, 30), + cv2.FONT_HERSHEY_SIMPLEX, 0.8, (0, 255, 0), 2) + cv2.putText(frame, f"距左边界: {track_info['distance_to_left']:.1f}px", (10, 70), + cv2.FONT_HERSHEY_SIMPLEX, 0.8, (0, 255, 0), 2) + cv2.putText(frame, f"帧: {frame_idx}/{frame_count}", (10, height-30), + cv2.FONT_HERSHEY_SIMPLEX, 0.8, (0, 255, 0), 2) + else: + # 如果检测失败,显示错误消息 + cv2.putText(frame, "未检测到轨迹线", (10, 50), + cv2.FONT_HERSHEY_SIMPLEX, 1, (0, 0, 255), 2) + + # 如果需要显示 + if show_steps: + cv2.imshow('Left Track Detection', frame) + key = cv2.waitKey(1) + if key == 27: # ESC键退出 + break + + # 如果需要保存 + if video_writer is not None: + video_writer.write(frame) + + # 清理资源 + cap.release() + if video_writer is not None: + video_writer.release() + cv2.destroyAllWindows() + + # 打印统计信息 + print(f"\n视频处理完成") + print(f"总帧数: {frame_count}") + print(f"成功检测帧数: {detect_success_count}") + print(f"检测成功率: {detect_success_count/frame_count*100:.2f}%") + if processing_times: + avg_time = sum(processing_times) / len(processing_times) + print(f"平均处理时间: {avg_time*1000:.2f}ms") + print(f"处理帧率: {1/avg_time:.2f}fps") + + if save_dir: + print(f"结果已保存至: {out_path}") + +def main(): + parser = argparse.ArgumentParser(description='左侧黄色轨迹线检测演示程序') + parser.add_argument('--input', type=str, default='res/path/image_20250513_162556.png', help='输入图像或视频的路径') + parser.add_argument('--output', type=str, default='res/path/test/left_track_results/', help='输出结果的保存目录') + parser.add_argument('--type', type=str, choices=['image', 'video'], help='输入类型,不指定会自动检测') + parser.add_argument('--show', default=True, help='显示处理步骤') + + args = parser.parse_args() + + # 检查输入路径 + if not os.path.exists(args.input): + print(f"错误:文件 '{args.input}' 不存在") + return + + # 如果未指定类型,根据文件扩展名判断 + if args.type is None: + ext = os.path.splitext(args.input)[1].lower() + if ext in ['.jpg', '.jpeg', '.png', '.bmp']: + args.type = 'image' + elif ext in ['.mp4', '.avi', '.mov']: + args.type = 'video' + else: + print(f"错误:无法确定文件类型 '{ext}'") + return + + # 根据类型处理 + if args.type == 'image': + process_image(args.input, args.output, args.show) + elif args.type == 'video': + process_video(args.input, args.output, args.show) + else: + print("错误:不支持的类型") + +if __name__ == "__main__": + main() \ No newline at end of file diff --git a/utils/detect_track.py b/utils/detect_track.py index 37ab7a5..5ec3b64 100644 --- a/utils/detect_track.py +++ b/utils/detect_track.py @@ -610,3 +610,240 @@ def detect_dual_track_lines(image, observe=False, delay=1000, save_log=True): } return center_info, left_track_info, right_track_info + +def detect_left_side_track(image, observe=False, delay=1000, save_log=True): + """ + 检测视野左侧黄色轨道线,用于机器狗左侧靠线移动 + + 参数: + image: 输入图像,可以是文件路径或者已加载的图像数组 + observe: 是否输出中间状态信息和可视化结果,默认为False + delay: 展示每个步骤的等待时间(毫秒) + save_log: 是否保存日志和图像 + + 返回: + tuple: (线信息字典, 最佳跟踪点) + """ + # 如果输入是字符串(文件路径),则加载图像 + if isinstance(image, str): + img = cv2.imread(image) + else: + img = image.copy() + + if img is None: + error("无法加载图像", "失败") + return None, None + + # 获取图像尺寸 + height, width = img.shape[:2] + + # 计算图像中间和左侧区域的范围 + center_x = width // 2 + # 主要关注视野的左半部分 + left_region_width = center_x + left_region_height = height + left_bound = 0 + right_bound = center_x + bottom_bound = height + top_bound = 0 + + if observe: + debug("步骤1: 原始图像已加载", "加载") + region_img = img.copy() + # 绘制左侧搜索区域 + cv2.rectangle(region_img, (left_bound, top_bound), (right_bound, bottom_bound), (255, 0, 0), 2) + cv2.line(region_img, (center_x, 0), (center_x, height), (0, 0, 255), 2) # 中线 + cv2.imshow("左侧搜索区域", region_img) + cv2.waitKey(delay) + + # 转换到HSV颜色空间以便更容易提取黄色 + hsv = cv2.cvtColor(img, cv2.COLOR_BGR2HSV) + + # 黄色的HSV范围 - 扩大范围以更好地捕捉不同光照条件下的黄色 + lower_yellow = np.array([15, 80, 80]) # 更宽松的黄色下限 + upper_yellow = np.array([35, 255, 255]) # 更宽松的黄色上限 + + # 创建黄色的掩码 + mask = cv2.inRange(hsv, lower_yellow, upper_yellow) + + # 形态学操作以改善掩码 + kernel = np.ones((5, 5), np.uint8) # 增大kernel尺寸 + mask = cv2.dilate(mask, kernel, iterations=1) + mask = cv2.erode(mask, np.ones((3, 3), np.uint8), iterations=1) # 添加腐蚀操作去除噪点 + + if observe: + debug("步骤2: 创建黄色掩码", "处理") + cv2.imshow("黄色掩码", mask) + cv2.waitKey(delay) + + # 裁剪左侧区域 + left_region_mask = mask[:, left_bound:right_bound] + + if observe: + debug("步骤3: 左侧区域掩码", "处理") + cv2.imshow("左侧区域掩码", left_region_mask) + cv2.waitKey(delay) + + # 边缘检测 + edges = cv2.Canny(mask, 50, 150, apertureSize=3) + + if observe: + debug("步骤4: 边缘检测", "处理") + cv2.imshow("边缘检测", edges) + cv2.waitKey(delay) + + # 霍夫变换检测直线 + lines = cv2.HoughLinesP(edges, 1, np.pi/180, threshold=25, + minLineLength=height*0.15, maxLineGap=50) # 调整参数以检测更长的线段 + + if lines is None or len(lines) == 0: + error("未检测到直线", "失败") + return None, None + + if observe: + debug(f"步骤5: 检测到 {len(lines)} 条直线", "处理") + lines_img = img.copy() + for line in lines: + x1, y1, x2, y2 = line[0] + cv2.line(lines_img, (x1, y1), (x2, y2), (0, 255, 0), 2) + cv2.imshow("检测到的直线", lines_img) + cv2.waitKey(delay) + + # 筛选左侧区域内的近似垂直线 + left_vertical_lines = [] + for line in lines: + x1, y1, x2, y2 = line[0] + + # 确保线在左侧区域内 + if not (max(x1, x2) <= right_bound): + continue + + # 计算斜率 (避免除零错误) + if abs(x2 - x1) < 5: # 几乎垂直的线 + slope = 100 # 设置一个较大的值表示接近垂直 + else: + slope = (y2 - y1) / (x2 - x1) + + # 筛选接近垂直的线 (斜率较大) + if abs(slope) > 0.7: # 设置较宽松的垂直线斜率阈值 + line_length = np.sqrt((x2-x1)**2 + (y2-y1)**2) + # 计算线的中点坐标 + mid_x = (x1 + x2) / 2 + mid_y = (y1 + y2) / 2 + + # 保存线段、其坐标、斜率和长度 + left_vertical_lines.append((line[0], mid_x, mid_y, slope, line_length)) + + if len(left_vertical_lines) == 0: + error("左侧区域未检测到垂直线", "失败") + return None, None + + if observe: + debug(f"步骤6: 左侧区域找到 {len(left_vertical_lines)} 条垂直线", "处理") + left_lines_img = img.copy() + for line_info in left_vertical_lines: + line, _, _, slope, _ = line_info + x1, y1, x2, y2 = line + cv2.line(left_lines_img, (x1, y1), (x2, y2), (0, 255, 255), 2) + # 显示斜率 + cv2.putText(left_lines_img, f"{slope:.2f}", ((x1+x2)//2, (y1+y2)//2), + cv2.FONT_HERSHEY_SIMPLEX, 0.5, (255, 0, 0), 1) + cv2.imshow("左侧垂直线", left_lines_img) + cv2.waitKey(delay) + + # 按线段长度和位置进行评分,优先选择更长且更靠近图像左边的线 + def score_left_line(line_info): + _, mid_x, _, _, length = line_info + # 线段越长分数越高 + length_score = min(1.0, length / (height * 0.3)) + # 越靠近左边分数越高 + position_score = 1.0 - (mid_x / center_x) + # 综合评分 + return length_score * 0.7 + position_score * 0.3 + + # 对线段进行评分并排序 + left_vertical_lines = sorted(left_vertical_lines, key=score_left_line, reverse=True) + + # 选择最佳的左侧线段 + best_left_line = left_vertical_lines[0] + line, mid_x, mid_y, slope, length = best_left_line + x1, y1, x2, y2 = line + + # 确保线段的顺序是从上到下 + if y1 > y2: + x1, x2 = x2, x1 + y1, y2 = y2, y1 + + # 计算最佳跟踪点 - 选择线段底部较靠近机器人的点 + tracking_point = (x2, y2) if y2 > y1 else (x1, y1) + + # 计算线与地面的交点 + # 使用线段的方程: (y - y1) = slope * (x - x1) + # 地面对应图像底部: y = height + # 解这个方程得到交点的x坐标 + if abs(slope) < 0.01: # 几乎垂直 + ground_intersection_x = x1 + else: + ground_intersection_x = x1 + (height - y1) / slope + ground_intersection = (int(ground_intersection_x), height) + + # 计算线与图像左边界的距离(以像素为单位) + distance_to_left = mid_x + + result_img = None + if observe or save_log: + result_img = img.copy() + # 绘制检测到的最佳左侧线 + cv2.line(result_img, (x1, y1), (x2, y2), (255, 0, 0), 2) + # 绘制图像中线 + cv2.line(result_img, (center_x, 0), (center_x, height), (0, 0, 255), 1) + # 标记最佳跟踪点和地面交点 + cv2.circle(result_img, tracking_point, 10, (0, 255, 0), -1) + cv2.circle(result_img, ground_intersection, 10, (0, 0, 255), -1) + + # 显示信息 + cv2.putText(result_img, f"斜率: {slope:.2f}", (10, 30), + cv2.FONT_HERSHEY_SIMPLEX, 1, (0, 255, 0), 2) + cv2.putText(result_img, f"距左边界: {distance_to_left:.1f}px", (10, 70), + cv2.FONT_HERSHEY_SIMPLEX, 1, (0, 255, 0), 2) + cv2.putText(result_img, f"地面交点: ({ground_intersection[0]}, {ground_intersection[1]})", (10, 110), + cv2.FONT_HERSHEY_SIMPLEX, 1, (0, 255, 0), 2) + + if observe: + debug("步骤7: 左侧最佳跟踪线和点", "显示") + cv2.imshow("左侧最佳跟踪线和点", result_img) + cv2.waitKey(delay) + + # 保存日志图像 + if save_log and result_img is not None: + timestamp = datetime.datetime.now().strftime("%Y%m%d_%H%M%S_%f") + log_dir = "logs/image" + os.makedirs(log_dir, exist_ok=True) + img_path = os.path.join(log_dir, f"left_track_{timestamp}.jpg") + cv2.imwrite(img_path, result_img) + info(f"保存左侧轨迹线检测结果图像到: {img_path}", "日志") + + # 保存文本日志信息 + log_info = { + "timestamp": timestamp, + "tracking_point": tracking_point, + "ground_intersection": ground_intersection, + "distance_to_left": distance_to_left, + "slope": slope, + "line_mid_x": mid_x + } + info(f"左侧轨迹线检测结果: {log_info}", "日志") + + # 创建线段信息字典 + track_info = { + "line": line, + "slope": slope, + "tracking_point": tracking_point, + "ground_intersection": ground_intersection, + "distance_to_left": distance_to_left, + "mid_x": mid_x, + "mid_y": mid_y, + "is_vertical": abs(slope) > 5.0 # 判断是否接近垂直 + } + + return track_info, tracking_point