Files
archery/laser_manager.py
gcw_4spBpAfv 945077a453 refind logger
2026-01-20 18:40:54 +08:00

1183 lines
54 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
激光管理器模块
提供激光控制、校准等功能
"""
import json
import os
import binascii
from maix import time, camera
import threading
import config
from logger_manager import logger_manager
import vision
class LaserManager:
"""激光控制管理器(单例)"""
_instance = None
def __new__(cls):
if cls._instance is None:
cls._instance = super(LaserManager, cls).__new__(cls)
cls._instance._initialized = False
return cls._instance
def __init__(self):
if self._initialized:
return
# 私有状态
self._calibration_active = False
self._calibration_result = None
self._calibration_lock = threading.Lock()
self._laser_point = None
self._laser_turned_on = False
self._last_frame_with_ellipse = None # 保存绘制了椭圆的图像(用于调试/显示)
self._initialized = True
# ==================== 状态访问(只读属性)====================
@property
def logger(self):
"""获取 logger 对象"""
return logger_manager.logger
@property
def calibration_active(self):
"""是否正在校准"""
return self._calibration_active
@property
def laser_point(self):
"""当前激光点(如果启用硬编码,则返回硬编码值)"""
if config.HARDCODE_LASER_POINT:
return config.HARDCODE_LASER_POINT_VALUE
return self._laser_point
def get_last_frame_with_ellipse(self):
"""
获取最后一次查找激光点时绘制了椭圆的图像(如果启用椭圆绘制)
Returns:
MaixPy 图像对象,如果没有则返回 None
"""
return self._last_frame_with_ellipse
# ==================== 业务方法 ====================
def load_laser_point(self):
"""从配置文件加载激光中心点,失败则使用默认值
如果启用硬编码模式,则直接使用硬编码值
"""
if config.HARDCODE_LASER_POINT:
# 硬编码模式:直接使用硬编码值
self._laser_point = config.HARDCODE_LASER_POINT_VALUE
self.logger.info(f"[LASER] 使用硬编码激光点: {self._laser_point}")
return self._laser_point
# 正常模式:从配置文件加载
try:
if "laser_config.json" in os.listdir("/root"):
with open(config.CONFIG_FILE, "r") as f:
data = json.load(f)
if isinstance(data, list) and len(data) == 2:
self._laser_point = (int(data[0]), int(data[1]))
self.logger.debug(f"[INFO] 加载激光点: {self._laser_point}")
return self._laser_point
else:
raise ValueError
else:
self._laser_point = config.DEFAULT_LASER_POINT
except:
self._laser_point = config.DEFAULT_LASER_POINT
return self._laser_point
def save_laser_point(self, point):
"""保存激光中心点到配置文件
如果启用硬编码模式,则不保存(直接返回 True
"""
if config.HARDCODE_LASER_POINT:
# 硬编码模式:不保存到文件,但更新内存中的值(虽然不会被使用)
self._laser_point = point
self.logger.info(f"[LASER] 硬编码模式已启用,跳过保存激光点: {point}")
return True
# 正常模式:保存到配置文件
try:
with open(config.CONFIG_FILE, "w") as f:
json.dump([point[0], point[1]], f)
self._laser_point = point
return True
except Exception as e:
self.logger.error(f"[LASER] 保存激光点失败: {e}")
return False
def turn_on_laser(self):
"""发送指令开启激光,并读取回包(部分模块支持)"""
from hardware import hardware_manager
if hardware_manager.distance_serial is None:
self.logger.error("[LASER] distance_serial 未初始化")
return None
# 打印调试信息
self.logger.info(f"[LASER] 发送开启命令: {config.LASER_ON_CMD.hex()}")
# 清空接收缓冲区
try:
hardware_manager.distance_serial.read(-1) # 清空缓冲区
except:
pass
# 发送命令
written = hardware_manager.distance_serial.write(config.LASER_ON_CMD)
self.logger.info(f"[LASER] 写入字节数: {written}")
# return None
# TODO: 暂时去掉这个等待
# 读取回包
resp = hardware_manager.distance_serial.read(len=20,timeout=10)
if resp:
self.logger.info(f"[LASER] 收到回包 ({len(resp)}字节): {resp.hex()}")
if resp == config.LASER_ON_CMD:
self.logger.info("✅ 激光开启指令已确认")
else:
self.logger.warning("🔇 无回包(可能正常或模块不支持回包)")
return resp
def turn_off_laser(self):
"""发送指令关闭激光"""
from hardware import hardware_manager
if hardware_manager.distance_serial is None:
self.logger.error("[LASER] distance_serial 未初始化")
return None
# 打印调试信息
self.logger.info(f"[LASER] 发送关闭命令: {config.LASER_OFF_CMD.hex()}")
# 清空接收缓冲区
try:
hardware_manager.distance_serial.read(-1)
except:
pass
# 发送命令
written = hardware_manager.distance_serial.write(config.LASER_OFF_CMD)
self.logger.info(f"[LASER] 写入字节数: {written}")
# 读取回包
resp = hardware_manager.distance_serial.read(20)
if resp:
self.logger.info(f"[LASER] 收到回包 ({len(resp)}字节): {resp.hex()}")
else:
self.logger.warning("🔇 无回包")
return resp
# 不用读回包
# return None
def flash_laser(self, duration_ms=1000):
"""闪一下激光(用于射箭反馈)"""
try:
self.turn_on_laser()
time.sleep_ms(duration_ms)
self.turn_off_laser()
except Exception as e:
self.logger.error(f"闪激光失败: {e}")
# def find_red_laser(self, frame, threshold=150):
# """在图像中查找最亮的红色激光点(基于 RGB 阈值)"""
# w, h = frame.width(), frame.height()
# img_bytes = frame.to_bytes()
# max_sum = 0
# best_pos = None
# for y in range(0, h, 2):
# for x in range(0, w, 2):
# idx = (y * w + x) * 3
# r, g, b = img_bytes[idx], img_bytes[idx+1], img_bytes[idx+2]
# if r > threshold and r > g * 2 and r > b * 2:
# rgb_sum = r + g + b
# if rgb_sum > max_sum:
# max_sum = rgb_sum
# best_pos = (x, y)
# return best_pos
# def find_red_laser(self, frame, threshold=150, search_radius=50):
# """
# 在图像中心附近查找最亮的红色激光点(基于 RGB 阈值)
# Args:
# frame: 图像帧
# threshold: 红色通道阈值默认150
# search_radius: 搜索半径像素从图像中心开始搜索默认150
# Returns:
# (x, y) 坐标,如果未找到则返回 None
# """
# w, h = frame.width(), frame.height()
# center_x, center_y = w // 2, h // 2
# # 只在中心区域搜索
# x_min = max(0, center_x - search_radius)
# x_max = min(w, center_x + search_radius)
# y_min = max(0, center_y - search_radius)
# y_max = min(h, center_y + search_radius)
# img_bytes = frame.to_bytes()
# max_score = 0
# best_pos = None
# for y in range(y_min, y_max, 2):
# for x in range(x_min, x_max, 2):
# idx = (y * w + x) * 3
# r, g, b = img_bytes[idx], img_bytes[idx+1], img_bytes[idx+2]
# # 判断是否为红色或过曝的红色(发白)
# is_red = False
# is_overexposed_red = False
# # 情况1正常红色r 明显大于 g 和 b
# if r > threshold and r > g * 2 and r > b * 2:
# is_red = True
# # 情况2过曝的红色发白r, g, b 都接近255但 r 仍然最大)
# # 过曝时r, g, b 都接近 255但 r 应该仍然是最高的
# elif r > 200 and g > 200 and b > 200: # 接近白色
# if r >= g and r >= b and (r - g) > 10 and (r - b) > 10: # r 仍然明显最大
# is_overexposed_red = True
# if is_red or is_overexposed_red:
# # 计算得分RGB 总和 + 距离中心权重(越靠近中心得分越高)
# rgb_sum = r + g + b
# # 计算到中心的距离
# dx = x - center_x
# dy = y - center_y
# distance_from_center = (dx * dx + dy * dy) ** 0.5
# # 距离权重:距离越近,权重越高(最大权重为 1.0,距离为 0 时)
# # 当距离为 search_radius 时,权重为 0.5
# distance_weight = 1.0 - (distance_from_center / search_radius) * 0.5
# distance_weight = max(0.5, distance_weight) # 最小权重 0.5
# # 综合得分RGB 总和 * 距离权重
# score = rgb_sum * distance_weight
# if score > max_score:
# max_score = score
# best_pos = (x, y)
# print("best_pos:", best_pos)
# return best_pos
def _is_point_in_ellipse(self, point, ellipse_params):
"""
判断点是否在椭圆内
Args:
point: 点坐标 (x, y)
ellipse_params: 椭圆参数 ((center_x, center_y), (width, height), angle)
Returns:
bool: 如果点在椭圆内返回 True否则返回 False
"""
if ellipse_params is None:
return True # 如果没有椭圆参数,不进行限制
import math
(cx, cy), (width, height), angle = ellipse_params
px, py = point
# 椭圆半长轴和半短轴
a = width / 2.0 # 半长轴
b = height / 2.0 # 半短轴
# 将点坐标平移到椭圆中心
dx = px - cx
dy = py - cy
# 旋转坐标系使椭圆的长轴与x轴对齐
# angle 是度,需要转换为弧度
angle_rad = math.radians(angle)
cos_a = math.cos(angle_rad)
sin_a = math.sin(angle_rad)
# 旋转后的坐标
x_rot = dx * cos_a + dy * sin_a
y_rot = -dx * sin_a + dy * cos_a
# 检查点是否在椭圆内:((x_rot/a)^2 + (y_rot/b)^2) <= 1
ellipse_value = (x_rot / a) ** 2 + (y_rot / b) ** 2
return ellipse_value <= 1.0
def find_red_laser_with_ellipse(self, frame, threshold=None, search_radius=None, ellipse_params=None):
"""
使用椭圆拟合方法查找激光点中心(更准确)
先找到所有红色像素,然后拟合椭圆找到中心
Args:
frame: 图像帧
threshold: 红色通道阈值如果为None使用config.LASER_DETECTION_THRESHOLD
search_radius: 搜索半径像素从图像中心开始搜索如果为None使用config.LASER_SEARCH_RADIUS
ellipse_params: 椭圆参数 ((center_x, center_y), (width, height), angle),用于限制激光点必须在椭圆内
Returns:
(x, y) 坐标,如果未找到则返回 None
注意:如果启用 LASER_DRAW_ELLIPSE会在原始 frame 上绘制椭圆(会修改输入图像)
"""
import cv2
import numpy as np
from maix import image
self.logger.debug(f"find_red_laser_with_ellipse start: {time.ticks_ms()}")
# 使用配置项
if threshold is None:
threshold = config.LASER_DETECTION_THRESHOLD
if search_radius is None:
search_radius = config.LASER_SEARCH_RADIUS
red_ratio = config.LASER_RED_RATIO
overexposed_threshold = config.LASER_OVEREXPOSED_THRESHOLD
overexposed_diff = config.LASER_OVEREXPOSED_DIFF
w, h = frame.width(), frame.height()
center_x, center_y = w // 2, h // 2
# 转换为 OpenCV 格式
img_cv = image.image2cv(frame, False, False)
# 只在中心区域搜索
x_min = max(0, center_x - search_radius)
x_max = min(w, center_x + search_radius)
y_min = max(0, center_y - search_radius)
y_max = min(h, center_y + search_radius)
# 提取ROI区域只处理搜索区域而不是整个图像
roi = img_cv[y_min:y_max, x_min:x_max]
if roi.size == 0:
self.logger.debug("[LASER] ROI区域为空")
return None
# 分离RGB通道向量化操作比循环快得多
r_channel = roi[:, :, 0].astype(np.int32) # 转换为int32避免溢出
g_channel = roi[:, :, 1].astype(np.int32)
b_channel = roi[:, :, 2].astype(np.int32)
# 情况1正常红色判断向量化
# r > threshold and r > g * red_ratio and r > b * red_ratio
mask_red = (r_channel > threshold) & \
(r_channel > (g_channel * red_ratio)) & \
(r_channel > (b_channel * red_ratio))
# 情况2过曝的红色判断向量化
# r > overexposed_threshold and g > overexposed_threshold and b > overexposed_threshold
# and r >= g and r >= b and (r - g) > overexposed_diff and (r - b) > overexposed_diff
mask_overexposed = (r_channel > overexposed_threshold) & \
(g_channel > overexposed_threshold) & \
(b_channel > overexposed_threshold) & \
(r_channel >= g_channel) & \
(r_channel >= b_channel) & \
((r_channel - g_channel) > overexposed_diff) & \
((r_channel - b_channel) > overexposed_diff)
# 合并两种情况的掩码
mask_combined = mask_red | mask_overexposed
# 转换为uint8格式
mask_roi = mask_combined.astype(np.uint8) * 255
self.logger.debug(f"ellipse fitting start: {time.ticks_ms()}")
# 查找轮廓(只在搜索区域内)
contours, _ = cv2.findContours(mask_roi, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE)
self.logger.debug(f"ellipse fitting end: {time.ticks_ms()}")
if not contours:
self.logger.debug("[LASER] 未找到红色像素区域")
return None
self.logger.debug(f"ellipse filtering start: {time.ticks_ms()}")
# 找到最大的轮廓(应该是激光点)
largest_contour = max(contours, key=cv2.contourArea)
# 检查轮廓面积(太小可能是噪声)
area = cv2.contourArea(largest_contour)
min_area = config.LASER_MIN_AREA
if area < min_area:
self.logger.debug(f"[LASER] 红色区域太小(面积={area:.1f}),可能是噪声(最小={min_area}")
return None
# 使用椭圆拟合找到中心
laser_center = None
outer_ellipse_params = None # 外层红色椭圆参数
inner_ellipse_params = None # 内层亮度椭圆参数
if len(largest_contour) >= 5:
# 椭圆拟合需要至少5个点
# 注意:需要将轮廓坐标转换回全图坐标
contour_global = largest_contour.copy()
for i in range(len(contour_global)):
contour_global[i][0][0] += x_min
contour_global[i][0][1] += y_min
try:
# 第一步:拟合外层红色椭圆
(x_outer, y_outer), (width_outer, height_outer), angle_outer = cv2.fitEllipse(contour_global)
outer_ellipse_params = ((x_outer, y_outer), (width_outer, height_outer), angle_outer)
self.logger.debug(f"[LASER] 外层红色椭圆拟合成功: 中心=({x_outer:.1f}, {y_outer:.1f}), 尺寸=({width_outer:.1f}, {height_outer:.1f}), 角度={angle_outer:.1f}°, 面积={area:.1f}")
# 第二步:在外层椭圆区域内,找亮度最高的像素
# 创建外层椭圆的掩码
outer_ellipse_mask = np.zeros((h, w), dtype=np.uint8)
cv2.ellipse(outer_ellipse_mask,
(int(x_outer), int(y_outer)),
(int(width_outer/2), int(height_outer/2)),
angle_outer,
0, 360,
255, -1) # 填充椭圆区域
# 在外层椭圆区域内计算每个像素的亮度RGB总和
brightness = (img_cv[:, :, 0].astype(np.int32) +
img_cv[:, :, 1].astype(np.int32) +
img_cv[:, :, 2].astype(np.int32))
# 只考虑外层椭圆区域内的像素
brightness_masked = np.where(outer_ellipse_mask > 0, brightness, 0)
# 找到亮度阈值使用区域内亮度的较高百分位比如80%
brightness_values = brightness_masked[brightness_masked > 0]
if len(brightness_values) > 0:
brightness_threshold = np.percentile(brightness_values, 90) # 取90%分位数
# 创建亮度掩码(只保留高亮度像素)
brightness_mask = (brightness_masked >= brightness_threshold).astype(np.uint8) * 255
# 查找亮度区域的轮廓
brightness_contours, _ = cv2.findContours(brightness_mask, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE)
# 在 brightness_contours 处理部分,添加 else 分支处理 brightness_area < 1 的情况
if brightness_contours:
# 找到最大的亮度轮廓
largest_brightness_contour = max(brightness_contours, key=cv2.contourArea)
brightness_area = cv2.contourArea(largest_brightness_contour)
if brightness_area >= 3 and len(largest_brightness_contour) >= 5:
# 第三步:拟合内层亮度椭圆
try:
(x_inner, y_inner), (width_inner, height_inner), angle_inner = cv2.fitEllipse(largest_brightness_contour)
inner_ellipse_params = ((x_inner, y_inner), (width_inner, height_inner), angle_inner)
laser_center = (float(x_inner), float(y_inner))
self.logger.debug(f"[LASER] 内层亮度椭圆拟合成功: 中心=({x_inner:.1f}, {y_inner:.1f}), 尺寸=({width_inner:.1f}, {height_inner:.1f}), 角度={angle_inner:.1f}°, 面积={brightness_area:.1f}")
except Exception as e:
# 内层椭圆拟合失败,使用质心
M = cv2.moments(largest_brightness_contour)
if M["m00"] != 0:
cx = M["m10"] / M["m00"]
cy = M["m01"] / M["m00"]
laser_center = (float(cx), float(cy))
self.logger.debug(f"[LASER] 内层亮度椭圆拟合失败,使用质心: {laser_center}, 错误: {e}")
else:
# 质心计算失败,使用外层椭圆中心
laser_center = (float(x_outer), float(y_outer))
self.logger.debug(f"[LASER] 内层区域质心计算失败,使用外层椭圆中心: {laser_center}")
elif brightness_area >= 1:
# 面积太小,使用质心
M = cv2.moments(largest_brightness_contour)
if M["m00"] != 0:
cx = M["m10"] / M["m00"]
cy = M["m01"] / M["m00"]
laser_center = (float(cx), float(cy))
self.logger.debug(f"[LASER] 内层区域质心计算成功: {laser_center}")
else:
# 质心计算失败,使用外层椭圆中心
laser_center = (float(x_outer), float(y_outer))
self.logger.debug(f"[LASER] 内层区域质心计算失败,使用外层椭圆中心: {laser_center}")
else:
# brightness_area < 1面积太小直接使用外层椭圆中心
laser_center = (float(x_outer), float(y_outer))
self.logger.debug(f"[LASER] 内层亮度区域面积太小({brightness_area:.1f}),使用外层椭圆中心: {laser_center}")
else:
# 没有找到亮度轮廓,使用外层椭圆中心
laser_center = (float(x_outer), float(y_outer))
self.logger.debug(f"[LASER] 外层椭圆区域内无有效亮度值,使用外层椭圆中心: {laser_center}")
else:
# 没有亮度值,使用外层椭圆中心
laser_center = (float(x_outer), float(y_outer))
self.logger.debug(f"[LASER] 外层椭圆区域内无有效亮度值,使用外层椭圆中心: {laser_center}")
# 如果启用绘制椭圆,在图像上绘制
if config.LASER_DRAW_ELLIPSE:
import math
# 绘制外层红色椭圆(绿色)
cx_outer, cy_outer = int(x_outer), int(y_outer)
cv2.ellipse(img_cv,
(cx_outer, cy_outer),
(int(width_outer/2), int(height_outer/2)),
angle_outer,
0, 360,
(0, 255, 0), # 绿色 (RGB格式)
2)
# 如果找到内层椭圆,绘制内层亮度椭圆(黄色)和中心点(红色)
if inner_ellipse_params is not None:
(x_inner, y_inner), (width_inner, height_inner), angle_inner = inner_ellipse_params
cx_inner, cy_inner = int(x_inner), int(y_inner)
# 绘制内层椭圆(黄色)
cv2.ellipse(img_cv,
(cx_inner, cy_inner),
(int(width_inner/2), int(height_inner/2)),
angle_inner,
0, 360,
(255, 255, 0), # 黄色 (RGB格式)
2)
# 绘制内层椭圆中心点(红色,较大)
cv2.circle(img_cv, (cx_inner, cy_inner), 5, (255, 0, 0), -1)
else:
# 只绘制外层椭圆中心点(红色)
cv2.circle(img_cv, (cx_outer, cy_outer), 3, (255, 0, 0), -1)
# 将绘制后的图像转换回 MaixPy 格式并保存到实例变量
from maix import image
self._last_frame_with_ellipse = image.cv2image(img_cv, False, False)
if inner_ellipse_params:
self.logger.debug(f"[LASER] 已绘制双层椭圆: 外层(绿色)中心=({cx_outer}, {cy_outer}), 内层(黄色)中心=({cx_inner}, {cy_inner})")
else:
self.logger.debug(f"[LASER] 已绘制外层椭圆: 中心=({cx_outer}, {cy_outer})")
except Exception as e:
laser_ellipse_params = None
# 椭圆拟合失败,使用质心
M = cv2.moments(contour_global)
if M["m00"] != 0:
cx = M["m10"] / M["m00"]
cy = M["m01"] / M["m00"]
laser_center = (float(cx), float(cy))
self.logger.debug(f"[LASER] 椭圆拟合失败,使用质心: {laser_center}, 错误: {e}")
self.logger.debug(f"ellipse filtering start: {time.ticks_ms()}")
else:
# 点太少,使用质心
contour_global = largest_contour.copy()
for i in range(len(contour_global)):
contour_global[i][0][0] += x_min
contour_global[i][0][1] += y_min
M = cv2.moments(contour_global)
if M["m00"] != 0:
cx = M["m10"] / M["m00"]
cy = M["m01"] / M["m00"]
laser_center = (float(cx), float(cy))
self.logger.debug(f"[LASER] 点太少({len(largest_contour)}个),使用质心: {laser_center}")
if laser_center is None:
# 清除之前保存的椭圆图像
self._last_frame_with_ellipse = None
return None
# 检查距离中心是否太远
final_x, final_y = laser_center
dx_final = final_x - center_x
dy_final = final_y - center_y
distance_from_center_final = (dx_final * dx_final + dy_final * dy_final) ** 0.5
max_distance = config.LASER_MAX_DISTANCE_FROM_CENTER
if distance_from_center_final > max_distance:
self.logger.warning(f"[LASER] 激光点距离中心太远: 位置={laser_center}, "
f"距离中心={distance_from_center_final:.1f}像素, "
f"最大允许距离={max_distance}像素")
return None
# 检查是否在黄心椭圆范围内(如果启用)
if config.LASER_REQUIRE_IN_ELLIPSE and ellipse_params is not None:
if not self._is_point_in_ellipse(laser_center, ellipse_params):
(ell_center, (ell_width, ell_height), ell_angle) = ellipse_params
self.logger.warning(f"[LASER] 激光点不在黄心椭圆内: 位置={laser_center}, "
f"椭圆中心={ell_center}, 椭圆尺寸=({ell_width:.1f}, {ell_height:.1f})")
return None
ellipse_info = ""
if config.LASER_REQUIRE_IN_ELLIPSE and ellipse_params is not None:
ellipse_info = f", 椭圆内检查: 通过"
elif not config.LASER_REQUIRE_IN_ELLIPSE:
ellipse_info = f", 椭圆检查: 已禁用"
self.logger.debug(f"[LASER] 找到激光点(椭圆拟合): 位置={laser_center}, "
f"距离中心={distance_from_center_final:.1f}像素{ellipse_info}")
if config.LASER_DRAW_ELLIPSE and self._last_frame_with_ellipse is not None:
self.logger.debug(f"[LASER] 已保存绘制了椭圆的图像,可通过 get_last_frame_with_ellipse() 获取")
return laser_center
def _find_red_laser_brightest(self, frame, threshold=None, search_radius=None, ellipse_params=None):
"""
在图像中心附近查找最亮的红色激光点(基于 RGB 阈值)
使用两阶段搜索:先粗搜索找到候选区域,再精细搜索找到最亮点
如果启用 LASER_REQUIRE_IN_ELLIPSE只有激光点落在黄心椭圆范围内才算有效
Args:
frame: 图像帧
threshold: 红色通道阈值如果为None使用config.LASER_DETECTION_THRESHOLD
search_radius: 搜索半径像素从图像中心开始搜索如果为None使用config.LASER_SEARCH_RADIUS
ellipse_params: 椭圆参数 ((center_x, center_y), (width, height), angle),用于限制激光点必须在椭圆内
如果 config.LASER_REQUIRE_IN_ELLIPSE 为 False则忽略此参数
Returns:
(x, y) 坐标,如果未找到或不在椭圆内(如果启用检查)则返回 None
"""
# 使用配置项,如果参数未提供则使用默认配置
if threshold is None:
threshold = config.LASER_DETECTION_THRESHOLD
if search_radius is None:
search_radius = config.LASER_SEARCH_RADIUS
red_ratio = config.LASER_RED_RATIO
overexposed_threshold = config.LASER_OVEREXPOSED_THRESHOLD
overexposed_diff = config.LASER_OVEREXPOSED_DIFF
w, h = frame.width(), frame.height()
center_x, center_y = w // 2, h // 2
# 只在中心区域搜索
x_min = max(0, center_x - search_radius)
x_max = min(w, center_x + search_radius)
y_min = max(0, center_y - search_radius)
y_max = min(h, center_y + search_radius)
img_bytes = frame.to_bytes()
max_score = 0
candidate_pos = None
# 用于调试:记录最接近但未满足条件的点
best_near_red = None
best_near_red_score = 0
best_near_red_rgb = None
# 第一阶段粗搜索每2像素采样找到候选点
for y in range(y_min, y_max, 2):
for x in range(x_min, x_max, 2):
idx = (y * w + x) * 3
r, g, b = img_bytes[idx], img_bytes[idx+1], img_bytes[idx+2]
# 判断是否为红色或过曝的红色(发白)
is_red = False
is_overexposed_red = False
# 情况1正常红色使用配置的倍数要求
if r > threshold and r > g * red_ratio and r > b * red_ratio:
is_red = True
# 情况2过曝的红色发白r, g, b 都接近255但 r 仍然最大)
elif r > overexposed_threshold and g > overexposed_threshold and b > overexposed_threshold:
if r >= g and r >= b and (r - g) > overexposed_diff and (r - b) > overexposed_diff:
is_overexposed_red = True
if is_red or is_overexposed_red:
# 计算得分RGB 总和 + 距离中心权重
rgb_sum = r + g + b
dx = x - center_x
dy = y - center_y
distance_from_center = (dx * dx + dy * dy) ** 0.5
distance_weight = 1.0 - (distance_from_center / search_radius) * 0.5
distance_weight = max(0.5, distance_weight)
score = rgb_sum * distance_weight
if score > max_score:
max_score = score
candidate_pos = (x, y)
else:
# 记录最接近但未满足条件的点(用于调试)
if r > threshold * 0.8: # 至少接近阈值
rgb_sum = r + g + b
# 计算接近度分数
ratio_score = min(r / (g + 1), r / (b + 1)) # 避免除零
near_score = rgb_sum * ratio_score
if near_score > best_near_red_score:
best_near_red_score = near_score
best_near_red = (x, y)
best_near_red_rgb = (r, g, b)
# 如果没有找到候选点,输出调试信息
if candidate_pos is None:
if best_near_red:
self.logger.debug(f"[LASER] 未找到激光点,最接近的点: 位置={best_near_red}, RGB={best_near_red_rgb}, "
f"阈值={threshold}, 倍数要求={red_ratio}, r/g={best_near_red_rgb[0]/(best_near_red_rgb[1]+1):.2f}, "
f"r/b={best_near_red_rgb[0]/(best_near_red_rgb[2]+1):.2f}")
else:
self.logger.debug(f"[LASER] 未找到激光点,搜索区域: ({x_min}, {y_min}) 到 ({x_max}, {y_max}), "
f"阈值={threshold}, 倍数要求={red_ratio}")
return None
# 第二阶段在候选点周围进行精细搜索1像素间隔
# 在候选点周围 5x5 或 7x7 区域内找最亮的点
refine_radius = 3 # 精细搜索半径(像素)
cx, cy = candidate_pos
x_min_fine = max(0, cx - refine_radius)
x_max_fine = min(w, cx + refine_radius + 1)
y_min_fine = max(0, cy - refine_radius)
y_max_fine = min(h, cy + refine_radius + 1)
max_brightness = 0
best_pos = candidate_pos
best_rgb = None
# 精细搜索1像素间隔只考虑亮度RGB总和
for y in range(y_min_fine, y_max_fine, 1):
for x in range(x_min_fine, x_max_fine, 1):
idx = (y * w + x) * 3
r, g, b = img_bytes[idx], img_bytes[idx+1], img_bytes[idx+2]
# 判断是否为红色或过曝的红色(使用配置的倍数要求)
is_red = False
is_overexposed_red = False
if r > threshold and r > g * red_ratio and r > b * red_ratio:
is_red = True
elif r > overexposed_threshold and g > overexposed_threshold and b > overexposed_threshold:
if r >= g and r >= b and (r - g) > overexposed_diff and (r - b) > overexposed_diff:
is_overexposed_red = True
if is_red or is_overexposed_red:
rgb_sum = r + g + b
# 精细搜索阶段只考虑亮度,不考虑距离权重
if rgb_sum > max_brightness:
max_brightness = rgb_sum
best_pos = (x, y)
best_rgb = (r, g, b)
# 检查找到的激光点是否满足条件
if best_pos:
final_x, final_y = best_pos
dx_final = final_x - center_x
dy_final = final_y - center_y
distance_from_center_final = (dx_final * dx_final + dy_final * dy_final) ** 0.5
# 检查1距离中心是否太远
max_distance = config.LASER_MAX_DISTANCE_FROM_CENTER
if distance_from_center_final > max_distance:
# 距离中心太远,拒绝这个结果
self.logger.warning(f"[LASER] 找到的激光点距离中心太远: 位置={best_pos}, "
f"距离中心={distance_from_center_final:.1f}像素, "
f"最大允许距离={max_distance}像素, 拒绝此结果")
return None
# 检查2是否在黄心椭圆范围内仅在启用时检查
if config.LASER_REQUIRE_IN_ELLIPSE and ellipse_params is not None:
if not self._is_point_in_ellipse(best_pos, ellipse_params):
# 不在椭圆内,拒绝这个结果
(ell_center, (ell_width, ell_height), ell_angle) = ellipse_params
self.logger.warning(f"[LASER] 找到的激光点不在黄心椭圆内: 位置={best_pos}, "
f"椭圆中心={ell_center}, 椭圆尺寸=({ell_width:.1f}, {ell_height:.1f}), "
f"椭圆角度={ell_angle:.1f}°, 拒绝此结果")
return None
# 输出成功找到激光点的日志
ellipse_info = ""
if config.LASER_REQUIRE_IN_ELLIPSE and ellipse_params is not None:
ellipse_info = f", 椭圆内检查: 通过"
elif not config.LASER_REQUIRE_IN_ELLIPSE:
ellipse_info = f", 椭圆检查: 已禁用"
self.logger.debug(f"[LASER] 找到激光点: 位置={best_pos}, RGB={best_rgb}, "
f"亮度={max_brightness}, 距离中心={distance_from_center_final:.1f}像素{ellipse_info}, "
f"阈值={threshold}, 倍数要求={red_ratio}")
return best_pos
def find_red_laser(self, frame, threshold=None, search_radius=None, ellipse_params=None):
"""
查找激光点(支持两种方法:椭圆拟合或最亮点)
根据 config.LASER_USE_ELLIPSE_FITTING 配置选择使用哪种方法
Args:
frame: 图像帧
threshold: 红色通道阈值如果为None使用config.LASER_DETECTION_THRESHOLD
search_radius: 搜索半径像素从图像中心开始搜索如果为None使用config.LASER_SEARCH_RADIUS
ellipse_params: 椭圆参数 ((center_x, center_y), (width, height), angle),用于限制激光点必须在椭圆内
Returns:
(x, y) 坐标,如果未找到则返回 None
"""
if config.LASER_USE_ELLIPSE_FITTING:
return self.find_red_laser_with_ellipse(frame, threshold, search_radius, ellipse_params)
else:
# 使用原来的最亮点方法
return self._find_red_laser_brightest(frame, threshold, search_radius, ellipse_params)
def calibrate_laser_position(self, timeout_ms=8000, check_sharpness=True):
"""
执行激光校准:循环拍照 → 检测靶心 → 检查激光点清晰度 → 找红点 → 保存坐标
只有检测到靶心时才读取激光点
Args:
timeout_ms: 超时时间毫秒默认8000ms
check_sharpness: 是否检查激光点清晰度默认True
Returns:
(x, y) 坐标,如果超时或失败则返回 None
"""
from camera_manager import camera_manager
# from vision import check_laser_point_sharpness, save_calibration_image, detect_circle_v3
import vision
from maix import time
start = time.ticks_ms()
# 注意:使用 abs(time.ticks_diff(start, time.ticks_ms())) 避免负数问题
while self._calibration_active and abs(time.ticks_diff(start, time.ticks_ms())) < timeout_ms:
try:
# 使用全局 camera_manager线程安全读取
frame = camera_manager.read_frame()
# 先检测靶心(仅在需要椭圆检查时)
ellipse_params_temp = None
center_temp = None
radius_temp = None
if config.LASER_REQUIRE_IN_ELLIPSE:
result_img_temp, center_temp, radius_temp, method_temp, best_radius1_temp, ellipse_params_temp = vision.detect_circle_v3(frame, None)
# 只有检测到靶心时才继续处理激光点
if center_temp is None or radius_temp is None:
self.logger.debug(f"[LASER] 未检测到靶心,跳过")
time.sleep_ms(60)
continue
# 检测到靶心,继续处理激光点
# 检查激光点清晰度(可选)
sharpness_score = None # 初始化清晰度分数
if check_sharpness:
try:
# 使用 check_laser_point_sharpness 检测激光点清晰度
# 该函数会自动查找激光点并检测其清晰度
# 仅在启用椭圆检查时传入椭圆参数
is_sharp, sharpness_score, laser_pos = vision.check_laser_point_sharpness(
frame,
laser_point=None, # 自动查找激光点
roi_size=30,
threshold=config.IMAGE_SHARPNESS_THRESHOLD,
ellipse_params=ellipse_params_temp if config.LASER_REQUIRE_IN_ELLIPSE else None
)
if laser_pos is None:
# 未找到激光点
self.logger.debug(f"[LASER] 未找到激光点,跳过")
time.sleep_ms(60)
continue
if not is_sharp:
# 激光点模糊
self.logger.debug(f"[LASER] 激光点模糊(清晰度: {sharpness_score:.2f}),跳过")
time.sleep_ms(60)
continue
# 激光点清晰,使用找到的激光点位置
pos = laser_pos
except Exception as e:
self.logger.warning(f"[LASER] 激光点清晰度检测失败: {e},继续处理")
# 检测失败时,回退到原来的方法:直接查找激光点
# 仅在启用椭圆检查时传入椭圆参数
pos = self.find_red_laser(frame, ellipse_params=ellipse_params_temp if config.LASER_REQUIRE_IN_ELLIPSE else None)
if pos is None:
time.sleep_ms(60)
continue
else:
# 不检查清晰度,直接查找激光点
# 仅在启用椭圆检查时传入椭圆参数
pos = self.find_red_laser(frame, ellipse_params=ellipse_params_temp if config.LASER_REQUIRE_IN_ELLIPSE else None)
if pos is None:
time.sleep_ms(60)
continue
# 找到清晰的激光点,保存校准图像
if pos:
# 保存校准图像(带标注)
try:
# 如果使用椭圆拟合且启用了椭圆绘制,使用绘制了椭圆的图像
frame_to_save = frame
if config.LASER_USE_ELLIPSE_FITTING and config.LASER_DRAW_ELLIPSE:
frame_with_ellipse = self.get_last_frame_with_ellipse()
if frame_with_ellipse is not None:
frame_to_save = frame_with_ellipse
vision.save_calibration_image(frame_to_save, pos)
except Exception as e:
self.logger.error(f"[LASER] 保存校准图像失败: {e}")
# 设置结果、停止校准、保存坐标
self.set_calibration_result(pos)
self.stop_calibration()
self.save_laser_point(pos)
if sharpness_score is not None:
self.logger.info(f"✅ 校准成功: {pos} (清晰度: {sharpness_score:.2f}, 靶心: {center_temp}, 半径: {radius_temp})")
else:
self.logger.info(f"✅ 校准成功: {pos} (靶心: {center_temp}, 半径: {radius_temp})")
return pos
# 未找到激光点,继续循环
time.sleep_ms(60)
except Exception as e:
self.logger.error(f"[LASER] 校准过程异常: {e}")
import traceback
self.logger.error(traceback.format_exc())
time.sleep_ms(200)
# 超时或校准被停止
if self._calibration_active:
self.logger.warning(f"[LASER] 校准超时({timeout_ms}ms")
else:
self.logger.info("[LASER] 校准已停止")
return None
def start_calibration(self):
"""开始校准(公共方法)"""
with self._calibration_lock:
if self._calibration_active:
return False
self._calibration_active = True
self._calibration_result = None
return True
def stop_calibration(self):
"""停止校准(公共方法)"""
with self._calibration_lock:
self._calibration_active = False
def set_calibration_result(self, result):
"""设置校准结果(内部方法)"""
with self._calibration_lock:
self._calibration_result = result
def get_calibration_result(self):
"""获取并清除校准结果(内部方法)"""
with self._calibration_lock:
result = self._calibration_result
self._calibration_result = None
return result
def parse_bcd_distance(self, bcd_bytes: bytes) -> float:
"""将 4 字节 BCD 码转换为距离(米)"""
if len(bcd_bytes) != 4:
return 0.0
try:
hex_string = binascii.hexlify(bcd_bytes).decode()
distance_int = int(hex_string)
return distance_int / 1000.0
except Exception as e:
self.logger.error(f"[LASER] BCD 解析失败: {e}")
return 0.0
def read_distance_from_laser_sensor(self):
"""发送测距指令并返回距离(米)和信号质量
返回: (distance_m, signal_quality) 元组,失败返回 (0.0, 0)
"""
from hardware import hardware_manager
if hardware_manager.distance_serial is None:
self.logger.error("[LASER] distance_serial 未初始化")
return (0.0, 0)
try:
# 清空缓冲区
try:
hardware_manager.distance_serial.read(-1)
except:
pass
# 打开激光
self.turn_on_laser()
self._laser_turned_on = True
# time.sleep_ms(500) # 需要一定时间让激光稳定
# 发送测距查询命令
hardware_manager.distance_serial.write(config.DISTANCE_QUERY_CMD)
# time.sleep_ms(500) # 测试结果:这里的等待没有用!
self.turn_off_laser()
self._laser_turned_on = False
# 这里的等待才是有效的大概350ms能读到数据
# 循环读取响应最多等待500ms
start_time = time.ticks_ms()
max_wait_ms = 500
response = None
while True:
# 检查是否超时
# 注意:使用 time.ticks_diff(start_time, time.ticks_ms()) 避免负数问题
elapsed_ms = abs(time.ticks_diff(start_time, time.ticks_ms()))
if elapsed_ms >= max_wait_ms:
self.logger.warning(f"[LASER] 读取超时 ({elapsed_ms}ms),未收到完整响应")
return (0.0, 0)
# 尝试读取数据
response = hardware_manager.distance_serial.read(config.DISTANCE_RESPONSE_LEN)
# 如果读到完整数据,立即返回
if response and len(response) == config.DISTANCE_RESPONSE_LEN:
elapsed_ms = abs(time.ticks_diff(start_time, time.ticks_ms()))
self.logger.debug(f"[LASER] 收到响应 ({elapsed_ms}ms)")
break
# 如果还没超时,短暂等待后继续尝试
time.sleep_ms(10) # 每次循环等待10ms避免CPU占用过高
# 验证响应格式
if response and len(response) == config.DISTANCE_RESPONSE_LEN:
if response[3] != 0x20:
if response[0] == 0xEE:
err_code = (response[7] << 8) | response[8]
self.logger.warning(f"[LASER] 模块错误代码: {hex(err_code)}")
return (0.0, 0)
# 解析BCD码距离
bcd_bytes = response[6:10]
distance_value_m = self.parse_bcd_distance(bcd_bytes)
signal_quality = (response[10] << 8) | response[11]
self.logger.debug(f"[LASER] 测距成功: {distance_value_m:.3f} m, 信号质量: {signal_quality}")
return (distance_value_m, signal_quality)
self.logger.warning(f"[LASER] 无效响应: {response.hex() if response else 'None'}")
return (0.0, 0)
except Exception as e:
self.logger.error(f"[LASER] 读取激光测距失败: {e}")
return (0.0, 0)
def calculate_laser_point_from_distance(self, distance_m):
"""
根据目标距离动态计算激光点在图像中的坐标
激光在摄像头下方,所以需要将图像中心的 y 值加上偏移
Args:
distance_m: 目标距离(米),例如到靶心的距离
Returns:
(x, y): 激光点在图像中的坐标
"""
# from vision import estimate_pixel
# 图像中心坐标
center_x = config.IMAGE_CENTER_X
center_y = config.IMAGE_CENTER_Y
# 计算激光在摄像头下方的像素偏移y 方向)
# 激光在摄像头下方,所以 y 值要增加(向下为正)
pixel_offset_y = estimate_pixel(config.LASER_CAMERA_OFFSET_CM, distance_m)
# 激光点坐标x 保持中心y 加上偏移,
laser_x = center_x
laser_y = center_y + int(pixel_offset_y)
self.logger.debug(f"[LASER] 根据距离 {distance_m:.2f}m 计算激光点: ({laser_x}, {laser_y}), 像素偏移: {pixel_offset_y:.2f}")
return (laser_x, laser_y)
def has_calibrated_point(self):
"""检查是否真正校准过(配置文件存在且不是默认值)"""
if config.HARDCODE_LASER_POINT:
return False # 硬编码模式下不算校准
# 检查配置文件是否存在
if "laser_config.json" not in os.listdir("/root"):
return False
# 检查当前值是否是默认值
if self._laser_point == config.DEFAULT_LASER_POINT:
return False
return self._laser_point is not None
def compute_laser_position(self, circle_center, laser_point, radius, method):
"""计算激光相对于靶心的偏移量(单位:厘米)
Args:
circle_center: 靶心中心坐标 (x, y)
laser_point: 激光点坐标 (x, y)
radius: 靶心半径(像素)
method: 检测方法("模糊" 或其他)
Returns:
(dx, dy): 激光相对于靶心的偏移量(厘米),如果输入无效则返回 (None, None)
"""
if not all([circle_center, radius, method]):
return None, None
cx, cy = circle_center
lx, ly = laser_point
# r = 22.16 * 5
r = radius * 5
self.logger.debug(f"compute_laser_position: circle_center: {circle_center} laser_point: {laser_point} radius: {radius} method: {method} r: {r}")
target_x = (lx-cx)/r*100
target_y = (ly-cy)/r*100
self.logger.info(f"lx{lx} ly: {ly} cx: {cx} cy: {cy} result_x: {target_x} result_y: {-target_y} real_r_x: {lx-cx} real_r_y: {-1*(ly-cy)}")
return (target_x, -target_y)
# # 根据检测方法动态调整靶心物理半径(简化模型)
# circle_r = (radius / 4.0) * 20.0 if method == "模糊" else (68 / 16.0) * 20.0
# dx = lx - cx
# dy = ly - cy
# return dx / (circle_r / 100.0), -dy / (circle_r / 100.0)
def quick_measure_distance(self):
"""
快速激光测距:打开激光 → 测距 → 关闭激光
激光开启时间最小化约500-600ms尽量不让用户觉察到
返回: (distance_m, signal_quality) 元组,失败返回 (0.0, 0)
"""
self._laser_turned_on = False
try:
# 等待激光稳定(最小延迟)
# time.sleep_ms(50)
# 读取距离和信号质量
result = self.read_distance_from_laser_sensor()
return result
except Exception as e:
self.logger.error(f"[LASER] 快速测距异常: {e}")
return (0.0, 0)
finally:
# 确保激光关闭
if self._laser_turned_on:
try:
self.turn_off_laser()
except Exception as e:
self.logger.error(f"[LASER] 关闭激光失败: {e}")
# 创建全局单例实例
laser_manager = LaserManager()
# ==================== 向后兼容的函数接口 ====================
def load_laser_point():
"""加载激光点(向后兼容接口)"""
return laser_manager.load_laser_point()
def save_laser_point(point):
"""保存激光点(向后兼容接口)"""
return laser_manager.save_laser_point(point)
def turn_on_laser():
"""开启激光(向后兼容接口)"""
return laser_manager.turn_on_laser()
def turn_off_laser():
"""关闭激光(向后兼容接口)"""
return laser_manager.turn_off_laser()
def flash_laser(duration_ms=1000):
"""闪激光(向后兼容接口)"""
return laser_manager.flash_laser(duration_ms)
def find_red_laser(frame, threshold=150):
"""查找红色激光点(向后兼容接口)"""
return laser_manager.find_red_laser(frame, threshold)
def calibrate_laser_position():
"""校准激光位置(向后兼容接口)"""
return laser_manager.calibrate_laser_position()