9 Commits
Author SHA1 Message Date
linyimin 5ab4ef2944 fix: 2026-06-10 10:15:11 +08:00
linyimin 577ff02c04 fix:20cm靶的兼容 2026-06-09 18:31:01 +08:00
linyimin 82d0008257 fix: 2026-06-09 11:53:22 +08:00
linyimin 373eeb786a fix: 2026-06-09 10:30:03 +08:00
linyimin 4500e62647 fix: 2026-06-08 17:56:21 +08:00
linyimin 49a84e80e1 fix: 2026-06-08 17:52:53 +08:00
linyimin 9654b79cec fix: 2026-06-08 17:50:31 +08:00
linyimin 1ea8c64a40 feat: conn wifi 2026-06-08 16:46:56 +08:00
linyimin 9dd6fef6f8 fix: 不保存图片 2026-06-08 13:55:37 +08:00
10 changed files with 794 additions and 76 deletions
+1 -1
View File
@@ -1,6 +1,6 @@
id: t11 id: t11
name: t11 name: t11
version: 2.15.9 version: 2.15.14
author: t11 author: t11
icon: '' icon: ''
desc: t11 desc: t11
+8 -1
View File
@@ -308,8 +308,15 @@ LASER_COLOR = (0, 255, 0) # RGB颜色
LASER_THICKNESS = 1 LASER_THICKNESS = 1
LASER_LENGTH = 2 LASER_LENGTH = 2
# ==================== 队列大小限制(防止内存泄漏) ====================
MAX_SEND_QUEUE_SIZE = 500 # 发送队列上限
MAX_TCP_PAYLOADS = 500 # AT TCP 载荷缓存上限
MAX_HTTP_EVENTS = 200 # AT HTTP 事件缓存上限
LOG_QUEUE_MAXSIZE = 10000 # 日志队列上限
MAX_CMD_THREADS = 10 # 并发命令线程上限(防止服务器下发命令时无限创建线程)
# ==================== 图像保存配置 ==================== # ==================== 图像保存配置 ====================
SAVE_IMAGE_ENABLED = True # 是否保存图像(True=保存,False=不保存) SAVE_IMAGE_ENABLED = False # 是否保存图像(True=保存,False=不保存)
PHOTO_DIR = "/root/phot" # 照片存储目录 PHOTO_DIR = "/root/phot" # 照片存储目录
MAX_IMAGES = 1000 MAX_IMAGES = 1000
# Stage2 调试目录(默认 PHOTO_DIR/stage2_roi)内 JPEG 最多保留张数;None 表示与 MAX_IMAGES 相同 # Stage2 调试目录(默认 PHOTO_DIR/stage2_roi)内 JPEG 最多保留张数;None 表示与 MAX_IMAGES 相同
+2 -2
View File
@@ -65,8 +65,8 @@ class LoggerManager:
backup_count = config.LOG_BACKUP_COUNT backup_count = config.LOG_BACKUP_COUNT
try: try:
# 创建日志队列(界队列) # 创建日志队列(界队列,防止内存泄漏;满时自动丢弃旧日志
self._log_queue = queue.Queue(-1) self._log_queue = queue.Queue(maxsize=config.LOG_QUEUE_MAXSIZE)
# 确保日志文件所在的目录存在 # 确保日志文件所在的目录存在
log_dir = os.path.dirname(log_file) log_dir = os.path.dirname(log_file)
+3 -4
View File
@@ -290,16 +290,14 @@ def cmd_str():
last_avg_abs = 0 last_avg_abs = 0
def _flush_pressure_buf(reason: str): def _flush_pressure_buf(reason: str):
if not config.AIR_PRESSURE_lOG:
return
nonlocal pressure_buf, pressure_sum, pressure_min, pressure_max, pressure_t0_ms, logger, pressure_abs_sum, last_avg_abs nonlocal pressure_buf, pressure_sum, pressure_min, pressure_max, pressure_t0_ms, logger, pressure_abs_sum, last_avg_abs
if not pressure_buf: if not pressure_buf:
return return
if config.AIR_PRESSURE_lOG:
t1_ms = time.ticks_ms() t1_ms = time.ticks_ms()
n = len(pressure_buf) n = len(pressure_buf)
avg = (pressure_sum / n) if n else 0 avg = (pressure_sum / n) if n else 0
avg_abs = (pressure_abs_sum / n) if n else 0 avg_abs = (pressure_abs_sum / n) if n else 0
# 一行输出:方便后处理画曲线;同时带上统计信息便于快速看波峰
line = ( line = (
f"[气压批量] reason={reason} " f"[气压批量] reason={reason} "
f"t0={pressure_t0_ms} t1={t1_ms} n={n} " f"t0={pressure_t0_ms} t1={t1_ms} n={n} "
@@ -311,13 +309,14 @@ def cmd_str():
logger.debug(line) logger.debug(line)
else: else:
print(line) print(line)
last_avg_abs = avg_abs
# 无论是否记录日志,都必须清空 buffer,否则内存泄漏
pressure_buf = [] pressure_buf = []
pressure_sum = 0 pressure_sum = 0
pressure_abs_sum = 0 pressure_abs_sum = 0
pressure_min = 4095 pressure_min = 4095
pressure_max = 0 pressure_max = 0
pressure_t0_ms = None pressure_t0_ms = None
last_avg_abs = avg_abs
# 主循环:检测扳机触发 → 拍照 → 分析 → 上报 # 主循环:检测扳机触发 → 拍照 → 分析 → 上报
while not app.need_exit(): while not app.need_exit():
+81 -18
View File
@@ -72,6 +72,10 @@ class NetworkManager:
self._raw_line_data = [] self._raw_line_data = []
self._manual_trigger_flag = False self._manual_trigger_flag = False
# 限制并发命令线程数
self._cmd_thread_lock = threading.Lock()
self._cmd_thread_count = 0
# 网络类型状态 # 网络类型状态
self._network_type = None # "wifi" 或 "4G" 或 None self._network_type = None # "wifi" 或 "4G" 或 None
# 本次上电曾因 WiFi 质量差切换到 4G 后,直至关机不再改回 WiFi # 本次上电曾因 WiFi 质量差切换到 4G 后,直至关机不再改回 WiFi
@@ -165,11 +169,15 @@ class NetworkManager:
self._password = password self._password = password
def _enqueue(self, item, high=False): def _enqueue(self, item, high=False):
"""线程安全地加入队列(内部方法)""" """线程安全地加入队列(内部方法),队列满时丢弃最旧消息"""
with self._queue_lock: with self._queue_lock:
if high: if high:
if len(self._high_send_queue) >= config.MAX_SEND_QUEUE_SIZE:
self._high_send_queue.pop(0)
self._high_send_queue.append(item) self._high_send_queue.append(item)
else: else:
if len(self._normal_send_queue) >= config.MAX_SEND_QUEUE_SIZE:
self._normal_send_queue.pop(0)
self._normal_send_queue.append(item) self._normal_send_queue.append(item)
self._send_event.set() self._send_event.set()
@@ -198,6 +206,29 @@ class NetworkManager:
"""获取队列锁(用于with语句)""" """获取队列锁(用于with语句)"""
return self._queue_lock return self._queue_lock
def _spawn_cmd_thread(self, target, args=()):
"""安全创建命令线程,限制并发数,防止无限创建导致内存耗尽"""
with self._cmd_thread_lock:
if self._cmd_thread_count >= config.MAX_CMD_THREADS:
self.logger.warning(
f"[NET] 并发命令线程已达上限({config.MAX_CMD_THREADS}),跳过: {getattr(target, '__name__', str(target))}"
)
return False
self._cmd_thread_count += 1
def _wrapper(*a):
try:
target(*a)
except Exception as e:
self.logger.error(f"[NET] 命令线程异常: {e}")
finally:
with self._cmd_thread_lock:
self._cmd_thread_count -= 1
import _thread
_thread.start_new_thread(_wrapper, args)
return True
# ==================== 业务方法 ==================== # ==================== 业务方法 ====================
def read_device_id(self): def read_device_id(self):
@@ -627,6 +658,38 @@ class NetworkManager:
2, 2,
) )
def _cmd600_conn_wifi(self, data_obj):
hardware_manager.start_idle_timer()
inner_data = data_obj.get("data", {}) if isinstance(data_obj, dict) else {}
self.logger.info(f"[conn wifi] cmd600 , data: {inner_data}")
ssid = inner_data.get("ssid")
password = inner_data.get("password")
try:
w = network.wifi.Wifi()
e = w.connect(ssid, password, wait=True, timeout=15)
err.check_raise(e, "connect wifi failed")
if self.logger:
self.logger.info(f"[ota] Connect success, got ip{w.get_ip()}")
self.safe_enqueue(
{
"cmd": 600,
"result": "success",
"wifi": w.get_ip(),
},
2,
)
except Exception as e:
self.logger.error(f"cmd600 失败: {e}")
self.safe_enqueue(
{
"cmd": 600,
"result": "conn fail",
"reason": str(e),
},
2,
)
self._switch_to_4g_due_to_poor_wifi()
def safe_enqueue(self, data_dict, msg_type=2, high=False): def safe_enqueue(self, data_dict, msg_type=2, high=False):
"""线程安全地将消息加入队列(公共方法)""" """线程安全地将消息加入队列(公共方法)"""
self._enqueue((msg_type, data_dict), high) self._enqueue((msg_type, data_dict), high)
@@ -1696,8 +1759,6 @@ class NetworkManager:
def tcp_main(self): def tcp_main(self):
"""TCP 主通信循环:登录、心跳、处理指令、发送数据""" """TCP 主通信循环:登录、心跳、处理指令、发送数据"""
import _thread
self.logger.info("[NET] TCP主线程启动") self.logger.info("[NET] TCP主线程启动")
send_hartbeat_fail_count = 0 send_hartbeat_fail_count = 0
@@ -1933,8 +1994,7 @@ class NetworkManager:
self.logger.info(f"[IMAGE_UPLOAD] 准备上传: {target_image} -> {key}") self.logger.info(f"[IMAGE_UPLOAD] 准备上传: {target_image} -> {key}")
# 在新线程中执行上传,避免阻塞主循环 # 在新线程中执行上传,避免阻塞主循环
import _thread self._spawn_cmd_thread(
_thread.start_new_thread(
self._upload_image_file, self._upload_image_file,
(target_image, upload_url, upload_token, key, shoot_id, outlink) (target_image, upload_url, upload_token, key, shoot_id, outlink)
) )
@@ -1962,8 +2022,7 @@ class NetworkManager:
else: else:
self.logger.info(f"[LOG_UPLOAD] 收到日志上传命令,key: {key}") self.logger.info(f"[LOG_UPLOAD] 收到日志上传命令,key: {key}")
# 在新线程中执行上传,避免阻塞主循环 # 在新线程中执行上传,避免阻塞主循环
import _thread self._spawn_cmd_thread(
_thread.start_new_thread(
self._upload_log_file_v2, self._upload_log_file_v2,
(upload_url, upload_token, key, outlink, include_rotated, max_files, (upload_url, upload_token, key, outlink, include_rotated, max_files,
archive_format) archive_format)
@@ -2078,7 +2137,7 @@ class NetworkManager:
if mode == "4g": if mode == "4g":
ota_manager._set_ota_url(ota_url) # 记录 OTA URL,供命令7使用 ota_manager._set_ota_url(ota_url) # 记录 OTA URL,供命令7使用
ota_manager._start_update_thread() ota_manager._start_update_thread()
_thread.start_new_thread(ota_manager.direct_ota_download_via_4g, (ota_url,)) self._spawn_cmd_thread(ota_manager.direct_ota_download_via_4g, (ota_url,))
else: # mode == "wifi" else: # mode == "wifi"
if not ssid or not password: if not ssid or not password:
self.logger.error("ota wifi mode requires ssid and password") self.logger.error("ota wifi mode requires ssid and password")
@@ -2087,7 +2146,7 @@ class NetworkManager:
self.logger.info(f"ssid: {ssid}") self.logger.info(f"ssid: {ssid}")
self.logger.info(f"password: {password}") self.logger.info(f"password: {password}")
ota_manager._start_update_thread() ota_manager._start_update_thread()
_thread.start_new_thread(ota_manager.handle_wifi_and_update, self._spawn_cmd_thread(ota_manager.handle_wifi_and_update,
(ssid, password, ota_url)) (ssid, password, ota_url))
elif inner_cmd == 6: elif inner_cmd == 6:
try: try:
@@ -2148,20 +2207,22 @@ class NetworkManager:
else: else:
self.logger.info(f"[LOG_UPLOAD] 收到日志上传命令,目标URL: {upload_url}") self.logger.info(f"[LOG_UPLOAD] 收到日志上传命令,目标URL: {upload_url}")
# 在新线程中执行上传,避免阻塞主循环 # 在新线程中执行上传,避免阻塞主循环
import _thread self._spawn_cmd_thread(
_thread.start_new_thread(
self._upload_log_file, self._upload_log_file,
(upload_url, wifi_ssid, wifi_password, include_rotated, max_files, (upload_url, wifi_ssid, wifi_password, include_rotated, max_files,
archive_format) archive_format)
) )
elif inner_cmd == 200: elif inner_cmd == 200:
self.logger.info("[LASER] cmd200 在后台线程执行检测") self.logger.info("[LASER] cmd200 在后台线程执行检测")
import _thread self._spawn_cmd_thread(self._cmd200_detect_laser, ())
_thread.start_new_thread(self._cmd200_detect_laser, ())
elif inner_cmd == 300: elif inner_cmd == 300:
self.logger.info("[New Ota] cmd300 在后台线程执行OTA") self.logger.info("[New Ota] cmd300 在后台线程执行OTA")
import _thread self._spawn_cmd_thread(self._cmd300_ota, (data_obj,))
_thread.start_new_thread(self._cmd300_ota, (data_obj,)) elif inner_cmd == 600:
self.logger.info("[conn wifi] cmd600 在后台线程执行连接wifi: {data_obj}")
self._spawn_cmd_thread(self._cmd600_conn_wifi, (data_obj,))
elif inner_cmd == 601:
pass
else: # data的结构不是 dict else: # data的结构不是 dict
self.logger.info(f"[NET] body={body}, {time.time()}") self.logger.info(f"[NET] body={body}, {time.time()}")
else: else:
@@ -2190,11 +2251,13 @@ class NetworkManager:
msg_type, data_dict = item msg_type, data_dict = item
pkt = self._netcore.make_packet(msg_type, data_dict) pkt = self._netcore.make_packet(msg_type, data_dict)
if not self.tcp_send_raw(pkt): if not self.tcp_send_raw(pkt):
# 发送失败:将消息放回队首,触发重连(避免丢消息 # 发送失败:将消息放回队首(队列满则丢弃
with self.get_queue_lock(): with self.get_queue_lock():
if item_is_high: if item_is_high:
if len(self._high_send_queue) < config.MAX_SEND_QUEUE_SIZE:
self._high_send_queue.insert(0, item) self._high_send_queue.insert(0, item)
else: else:
if len(self._normal_send_queue) < config.MAX_SEND_QUEUE_SIZE:
self._normal_send_queue.insert(0, item) self._normal_send_queue.insert(0, item)
self._tcp_connected = False self._tcp_connected = False
try: try:
@@ -2262,7 +2325,7 @@ class NetworkManager:
self._tcp_connected = False self._tcp_connected = False
self.logger.error("连接异常,2秒后重连...") self.logger.error("连接异常,2秒后重连...")
time.sleep_ms(2000) time.sleep_ms(200)
except Exception as e: except Exception as e:
# TCP主循环的顶层异常捕获,防止线程静默退出 # TCP主循环的顶层异常捕获,防止线程静默退出
@@ -2270,7 +2333,7 @@ class NetworkManager:
import traceback import traceback
self.logger.error(traceback.format_exc()) self.logger.error(traceback.format_exc())
self._tcp_connected = False self._tcp_connected = False
time.sleep_ms(5000) # 等待5秒后重试连接 time.sleep_ms(500) # 等待5秒后重试连接
# 创建全局单例实例 # 创建全局单例实例
+617
View File
@@ -0,0 +1,617 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
离线测试脚本:直接复用 detect_circle 逻辑进行测试
运行环境:MaixPy (Sipeed MAIX)
"""
import sys
import os
# import time
from maix import image, time
import cv2
import numpy as np
# ==================== 全局配置 (与 test_main.py 保持一致) ====================
REAL_RADIUS_CM = 20 # 靶心实际半径(厘米)
# ==================== 复制的核心算法 ====================
# 注意:这里直接复制了 detect_circle 的逻辑,避免 import main 导致的冲突
def detect_circle_v3(frame, laser_point=None):
"""检测图像中的靶心(优先清晰轮廓,其次黄色区域)- 返回椭圆参数版本
增加红色圆圈检测,验证黄色圆圈是否为真正的靶心
如果提供 laser_point,会选择最接近激光点的目标
Args:
frame: 图像帧
laser_point: 激光点坐标 (x, y),用于多目标场景下的目标选择
Returns:
(result_img, best_center, best_radius, method, best_radius1, ellipse_params)
"""
img_cv = image.image2cv(frame, False, False)
best_center = best_radius = best_radius1 = method = None
ellipse_params = None
# HSV 黄色掩码检测(模糊靶心)
hsv = cv2.cvtColor(img_cv, cv2.COLOR_RGB2HSV)
h, s, v = cv2.split(hsv)
# 调整饱和度策略:稍微增强,不要过度
s = np.clip(s * 1.1, 0, 255).astype(np.uint8)
hsv = cv2.merge((h, s, v))
# 放宽 HSV 阈值范围(针对模糊图像的关键调整)
lower_yellow = np.array([7, 80, 0]) # 饱和度下限降低,捕捉淡黄色
upper_yellow = np.array([32, 255, 255]) # 亮度上限拉满
mask_yellow = cv2.inRange(hsv, lower_yellow, upper_yellow)
# 调整形态学操作
kernel = cv2.getStructuringElement(cv2.MORPH_ELLIPSE, (5, 5))
mask_yellow = cv2.morphologyEx(mask_yellow, cv2.MORPH_CLOSE, kernel)
contours_yellow, _ = cv2.findContours(mask_yellow, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE)
# 存储所有有效的黄色-红色组合
valid_targets = []
if contours_yellow:
for cnt_yellow in contours_yellow:
area = cv2.contourArea(cnt_yellow)
perimeter = cv2.arcLength(cnt_yellow, True)
# 计算圆度
if perimeter > 0:
circularity = (4 * np.pi * area) / (perimeter * perimeter)
else:
circularity = 0
if area > 50 and circularity > 0.7:
print(f"[target] -> 面积:{area}, 圆度:{circularity:.2f}")
# 尝试拟合椭圆
yellow_center = None
yellow_radius = None
yellow_ellipse = None
if len(cnt_yellow) >= 5:
(x, y), (width, height), angle = cv2.fitEllipse(cnt_yellow)
yellow_ellipse = ((x, y), (width, height), angle)
axes_minor = min(width, height)
radius = axes_minor / 2
yellow_center = (int(x), int(y))
yellow_radius = int(radius)
else:
(x, y), radius = cv2.minEnclosingCircle(cnt_yellow)
yellow_center = (int(x), int(y))
yellow_radius = int(radius)
yellow_ellipse = None
# 如果检测到黄色圆圈,再检测红色圆圈进行验证
if yellow_center and yellow_radius:
# HSV 红色掩码检测(红色在HSV中跨越0度,需要两个范围)
# 红色范围1: 0-10度(接近0度的红色)
lower_red1 = np.array([0, 50, 40])
upper_red1 = np.array([10, 255, 255])
mask_red1 = cv2.inRange(hsv, lower_red1, upper_red1)
# 红色范围2: 170-180度(接近180度的红色)
lower_red2 = np.array([170, 50, 40])
upper_red2 = np.array([180, 255, 255])
mask_red2 = cv2.inRange(hsv, lower_red2, upper_red2)
# 合并两个红色掩码
mask_red = cv2.bitwise_or(mask_red1, mask_red2)
# 形态学操作
kernel_red = cv2.getStructuringElement(cv2.MORPH_ELLIPSE, (5, 5))
mask_red = cv2.morphologyEx(mask_red, cv2.MORPH_CLOSE, kernel_red)
contours_red, _ = cv2.findContours(mask_red, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE)
found_valid_red = False
if contours_red:
# 找到所有符合条件的红色圆圈
for cnt_red in contours_red:
area_red = cv2.contourArea(cnt_red)
perimeter_red = cv2.arcLength(cnt_red, True)
if perimeter_red > 0:
circularity_red = (4 * np.pi * area_red) / (perimeter_red * perimeter_red)
else:
circularity_red = 0
# 红色圆圈也应该有一定的圆度
if area_red > 30 and circularity_red > 0.4:
# 计算红色圆圈的中心和半径
if len(cnt_red) >= 5:
(x_red, y_red), (w_red, h_red), angle_red = cv2.fitEllipse(cnt_red)
radius_red = min(w_red, h_red) / 2
red_center = (int(x_red), int(y_red))
red_radius = int(radius_red)
else:
(x_red, y_red), radius_red = cv2.minEnclosingCircle(cnt_red)
red_center = (int(x_red), int(y_red))
red_radius = int(radius_red)
# 计算黄色和红色圆心的距离
if red_center:
dx = yellow_center[0] - red_center[0]
dy = yellow_center[1] - red_center[1]
distance = np.sqrt(dx * dx + dy * dy)
# 圆心距离阈值:应该小于黄色半径的某个倍数(比如1.5倍)
max_distance = yellow_radius * 1.5
# 红色圆圈应该比黄色圆圈大(外圈)
if distance < max_distance and red_radius > yellow_radius * 0.7:
found_valid_red = True
print(
f"[target] -> 找到匹配的红圈: 黄心({yellow_center}), 红心({red_center}), 距离:{distance:.1f}, 黄半径:{yellow_radius}, 红半径:{red_radius}")
# 记录这个有效目标
valid_targets.append({
'center': yellow_center,
'radius': yellow_radius,
'ellipse': yellow_ellipse,
'area': area
})
break
if not found_valid_red:
print("Debug -> 未找到匹配的红色圆圈,可能是误识别")
# 从所有有效目标中选择最佳目标
if valid_targets:
if laser_point:
# 如果有激光点,选择最接近激光点的目标
best_target = None
min_distance = float('inf')
for target in valid_targets:
dx = target['center'][0] - laser_point[0]
dy = target['center'][1] - laser_point[1]
distance = np.sqrt(dx * dx + dy * dy)
if distance < min_distance:
min_distance = distance
best_target = target
if best_target:
best_center = best_target['center']
best_radius = best_target['radius']
ellipse_params = best_target['ellipse']
method = "v3_ellipse_red_validated_laser_selected"
best_radius1 = best_radius * 5
else:
# 如果没有激光点,选择面积最大的目标
best_target = max(valid_targets, key=lambda t: t['area'])
best_center = best_target['center']
best_radius = best_target['radius']
ellipse_params = best_target['ellipse']
method = "v3_ellipse_red_validated"
best_radius1 = best_radius * 5
result_img = image.cv2image(img_cv, False, False)
return result_img, best_center, best_radius, method, best_radius1, ellipse_params
def detect_circle(frame):
"""检测图像中的靶心(优先清晰轮廓,其次黄色区域)"""
img_cv = image.image2cv(frame, False, False)
# gray = cv2.cvtColor(img_cv, cv2.COLOR_RGB2GRAY)
# blurred = cv2.GaussianBlur(gray, (5, 5), 0)
# edged = cv2.Canny(blurred, 50, 150)
# kernel = cv2.getStructuringElement(cv2.MORPH_ELLIPSE, (5, 5))
# ceroded = cv2.erode(cv2.dilate(edged, kernel), kernel)
# contours, _ = cv2.findContours(ceroded, cv2.RETR_TREE, cv2.CHAIN_APPROX_SIMPLE)
# best_center = best_radius = best_radius1 = method = None
# hsv = cv2.cvtColor(img_cv, cv2.COLOR_RGB2HSV)
# h, s, v = cv2.split(hsv)
# s = np.clip(s * 2, 0, 255).astype(np.uint8)
# hsv = cv2.merge((h, s, v))
# lower_yellow = np.array([7, 80, 0])
# upper_yellow = np.array([32, 255, 182])
# mask = cv2.inRange(hsv, lower_yellow, upper_yellow)
# kernel = cv2.getStructuringElement(cv2.MORPH_ELLIPSE, (5, 5))
# mask = cv2.morphologyEx(mask, cv2.MORPH_OPEN, kernel)
# mask = cv2.morphologyEx(mask, cv2.MORPH_DILATE, kernel)
# contours, _ = cv2.findContours(mask, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE)
# if contours:
# largest = max(contours, key=cv2.contourArea)
# if cv2.contourArea(largest) > 50:
# (x, y), radius = cv2.minEnclosingCircle(largest)
# best_center = (int(x), int(y))
# best_radius = int(radius)
# best_radius1 = radius * 5
# method = "v2"
# auto
# R:31 M:v2 D:2.410110127692767
# hsv = cv2.cvtColor(img_cv, cv2.COLOR_RGB2HSV)
# h, s, v = cv2.split(hsv)
# # 1. 增强饱和度(模糊照片需要更强的增强)
# s = np.clip(s * 2.5, 0, 255).astype(np.uint8) # 从2.0改为2.5
# # 2. 增强亮度(模糊照片可能偏暗)
# v = np.clip(v * 1.2, 0, 255).astype(np.uint8) # 新增:提升亮度
# hsv = cv2.merge((h, s, v))
# # 3. 放宽HSV颜色范围(特别是模糊照片)
# # 降低饱和度下限,提高亮度上限
# lower_yellow = np.array([5, 50, 30]) # H:5-35, S:50-255, V:30-255
# upper_yellow = np.array([35, 255, 255])
# mask = cv2.inRange(hsv, lower_yellow, upper_yellow)
# # 4. 增强形态学操作(连接被分割的区域)
# kernel_small = cv2.getStructuringElement(cv2.MORPH_ELLIPSE, (5, 5))
# kernel_large = cv2.getStructuringElement(cv2.MORPH_ELLIPSE, (9, 9)) # 更大的核
# # 先开运算去除噪声
# mask = cv2.morphologyEx(mask, cv2.MORPH_OPEN, kernel_small)
# # 多次膨胀连接区域(模糊照片需要更多膨胀)
# mask = cv2.dilate(mask, kernel_large, iterations=2) # 增加迭代次数
# mask = cv2.morphologyEx(mask, cv2.MORPH_CLOSE, kernel_large) # 闭运算填充空洞
# contours, _ = cv2.findContours(mask, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE)
# if contours:
# largest = max(contours, key=cv2.contourArea)
# area = cv2.contourArea(largest)
# if area > 50:
# # 5. 使用面积计算等效半径(更准确)
# equivalent_radius = np.sqrt(area / np.pi)
# # 6. 同时使用minEnclosingCircle作为备选(取较大值)
# (x, y), enclosing_radius = cv2.minEnclosingCircle(largest)
# # 取两者中的较大值,确保不遗漏
# radius = max(equivalent_radius, enclosing_radius)
# best_center = (int(x), int(y))
# best_radius = int(radius)
# best_radius1 = radius * 5
# method = "v2"
# codegee
# R:24 M:v2 D:3.061493895819174
# R:22 M:v2 D:3.3644971681267077 np.clip(s * 1.1, 0, 255)
hsv = cv2.cvtColor(img_cv, cv2.COLOR_RGB2HSV)
h, s, v = cv2.split(hsv)
# 2. 调整饱和度策略:
# 不要暴力翻倍,可以尝试稍微增强,或者使用 CLAHE 增强亮度/对比度
# 这里我们稍微增加一点饱和度,并确保不溢出
s = np.clip(s * 1.1, 0, 255).astype(np.uint8)
# 对亮度通道 v 也可以做一点 CLAHE 处理来增强对比度(可选)
# clahe = cv2.createCLAHE(clipLimit=2.0, tileGridSize=(8,8))
# v = clahe.apply(v)
hsv = cv2.merge((h, s, v))
# 3. 放宽 HSV 阈值范围(针对模糊图像的关键调整)
# 降低 S 的下限 (80 -> 35),提高 V 的上限 (182 -> 255)
lower_yellow = np.array([7, 80, 0]) # 饱和度下限降低,捕捉淡黄色
upper_yellow = np.array([32, 255, 255]) # 亮度上限拉满
mask = cv2.inRange(hsv, lower_yellow, upper_yellow)
# 4. 调整形态学操作
# 去掉 MORPH_OPEN,因为它会减小面积。
# 使用 MORPH_CLOSE (先膨胀后腐蚀) 来填充内部小黑洞,连接近邻区域
kernel = cv2.getStructuringElement(cv2.MORPH_ELLIPSE, (5, 5))
mask = cv2.morphologyEx(mask, cv2.MORPH_CLOSE, kernel)
# 再进行一次膨胀,确保边缘被包含进来
# mask = cv2.dilate(mask, kernel, iterations=1)
contours, _ = cv2.findContours(mask, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE)
if contours:
largest = max(contours, key=cv2.contourArea)
# 这里可以适当降低面积阈值,或者保持不变
if cv2.contourArea(largest) > 50:
# (x, y), radius = cv2.minEnclosingCircle(largest)
# best_center = (int(x), int(y))
# best_radius = int(radius)
# --- 核心修改开始 ---
# 1. 尝试拟合椭圆 (需要轮廓点至少为5个)
if len(largest) >= 5:
# 返回值: ((中心x, 中心y), (长轴, 短轴), 旋转角度)
(x, y), (axes_major, axes_minor), angle = cv2.fitEllipse(largest)
# 2. 计算半径
# 选项A:取长短轴的平均值 (比较稳健)
# radius = (axes_major + axes_minor) / 4
# 选项B:直接取短轴的一半 (抗模糊最强,推荐)
radius = axes_minor / 2
best_center = (int(x), int(y))
best_radius = int(radius)
method = "v2_ellipse"
else:
# 如果点太少无法拟合椭圆,降级回 minEnclosingCircle
(x, y), radius = cv2.minEnclosingCircle(largest)
best_center = (int(x), int(y))
best_radius = int(radius)
method = "v2"
# --- 核心修改结束 ---
# 你的后续逻辑
best_radius1 = radius * 5
# operas 4.5
# R:25 M:v2 D:2.9554872521538527
# hsv = cv2.cvtColor(img_cv, cv2.COLOR_RGB2HSV)
# h, s, v = cv2.split(hsv)
# # 1. 适度增强饱和度(不要过度,否则噪声也会增强)
# s = np.clip(s * 1.5, 0, 255).astype(np.uint8)
# hsv = cv2.merge((h, s, v))
# # 2. 放宽 HSV 阈值范围(关键改动)
# # - 饱和度下限从 80 降到 40(捕捉淡黄色)
# # - 亮度上限从 182 提高到 255(允许更亮的黄色)
# lower_yellow = np.array([7, 40, 30])
# upper_yellow = np.array([35, 255, 255])
# mask = cv2.inRange(hsv, lower_yellow, upper_yellow)
# # 3. 调整形态学操作:用 CLOSE 替代 OPEN
# # CLOSE(先膨胀后腐蚀):填充内部空洞,连接相邻区域
# # OPEN(先腐蚀后膨胀):会缩小区域,不适合模糊图像
# kernel = cv2.getStructuringElement(cv2.MORPH_ELLIPSE, (7, 7)) # 稍大的核
# mask = cv2.morphologyEx(mask, cv2.MORPH_CLOSE, kernel)
# mask = cv2.dilate(mask, kernel, iterations=1) # 额外膨胀,确保边缘被包含
# contours, _ = cv2.findContours(mask, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE)
# if contours:
# largest = max(contours, key=cv2.contourArea)
# if cv2.contourArea(largest) > 50:
# (x, y), radius = cv2.minEnclosingCircle(largest)
# best_center = (int(x), int(y))
# best_radius = int(radius)
# best_radius1 = radius * 5
# method = "v2"
# # --- 新增:将 Mask 叠加到原图上用于调试 ---
# # 创建一个彩色掩码(红色通道为255,其他为0)
# mask_overlay = np.zeros_like(img_cv)
# mask_overlay[:, :, 2] = mask # 将掩码放在红色通道 (BGR中的R)
#
# cv2.addWeighted(img_cv, 0.6, mask_overlay, 0.4, 0, img_cv)
result_img = image.cv2image(img_cv, False, False)
return result_img, best_center, best_radius, method, best_radius1
def detect_circle_v2(frame):
"""检测图像中的靶心(优先清晰轮廓,其次黄色区域)- 返回椭圆参数版本"""
global REAL_RADIUS_CM
img_cv = image.image2cv(frame, False, False)
best_center = best_radius = best_radius1 = method = None
ellipse_params = None # 存储椭圆参数 ((x, y), (axes_major, axes_minor), angle)
# HSV 黄色掩码检测(模糊靶心)
hsv = cv2.cvtColor(img_cv, cv2.COLOR_RGB2HSV)
h, s, v = cv2.split(hsv)
# 调整饱和度策略:稍微增强,不要过度
s = np.clip(s * 1.1, 0, 255).astype(np.uint8)
hsv = cv2.merge((h, s, v))
# 放宽 HSV 阈值范围(针对模糊图像的关键调整)
lower_yellow = np.array([7, 80, 0]) # 饱和度下限降低,捕捉淡黄色
upper_yellow = np.array([32, 255, 255]) # 亮度上限拉满
mask = cv2.inRange(hsv, lower_yellow, upper_yellow)
# 调整形态学操作
# 使用 MORPH_CLOSE (先膨胀后腐蚀) 来填充内部小黑洞,连接近邻区域
kernel = cv2.getStructuringElement(cv2.MORPH_ELLIPSE, (5, 5))
mask = cv2.morphologyEx(mask, cv2.MORPH_CLOSE, kernel)
contours, _ = cv2.findContours(mask, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE)
if contours:
largest = max(contours, key=cv2.contourArea)
if cv2.contourArea(largest) > 50:
# 尝试拟合椭圆 (需要轮廓点至少为5个)
if len(largest) >= 5:
# 返回值: ((中心x, 中心y), (width, height), 旋转角度)
# 注意:width 和 height 是外接矩形的尺寸,不是长轴和短轴
(x, y), (width, height), angle = cv2.fitEllipse(largest)
# 保存椭圆参数(保持原始顺序,用于绘制)
ellipse_params = ((x, y), (width, height), angle)
# 计算半径:使用较小的尺寸作为短轴
axes_minor = min(width, height)
radius = axes_minor / 2
best_center = (int(x), int(y))
best_radius = int(radius)
method = "v2_ellipse"
else:
# 如果点太少无法拟合椭圆,降级回 minEnclosingCircle
(x, y), radius = cv2.minEnclosingCircle(largest)
best_center = (int(x), int(y))
best_radius = int(radius)
method = "v2"
ellipse_params = None # 圆形,没有椭圆参数
best_radius1 = radius * 5
result_img = image.cv2image(img_cv, False, False)
return result_img, best_center, best_radius, method, best_radius1, ellipse_params
# ==================== 测试逻辑 ====================
def run_offline_test(image_path):
"""读取图片,检测圆,绘制结果,保存图片"""
# 1. 检查文件是否存在
if not os.path.exists(image_path):
print(f"[ERROR] 找不到图片文件: {image_path}")
return
# 2. 使用 maix.image 读取图片 (适配 MaixPy v4)
try:
# 使用 image.load 读取文件,返回 Image 对象
img = image.load(image_path)
print(f"[INFO] 成功读取图片: {image_path} (尺寸: {img.width()}x{img.height()})")
except Exception as e:
print(f"[ERROR] 读取图片失败: {e}")
print("提示:请确认 MaixPy 版本是否为 v4,且图片路径正确。")
return
# 3. 调用 detect_circle_v2 函数
print("[INFO] 正在调用 detect_circle_v2 进行检测...")
start_time = time.ticks_ms()
result_img, center, radius, method, radius1, ellipse_params = detect_circle_v3(img)
cost_time = time.ticks_ms() - start_time
print(f"[INFO] 检测完成,耗时: {cost_time}ms")
print(f" 结果 -> 圆心: {center}, 半径: {radius}, 方法: {method}")
if ellipse_params:
(ell_center, (width, height), angle) = ellipse_params
print(
f" 椭圆 -> 中心: ({ell_center[0]:.1f}, {ell_center[1]:.1f}), 长轴: {max(width, height):.1f}, 短轴: {min(width, height):.1f}, 角度: {angle:.1f}°")
# 4. 绘制辅助线(可选,用于调试)
if center and radius:
# 为了绘制椭圆,需要转换回 cv2 图像
img_cv = image.image2cv(result_img, False, False)
cx, cy = center
# 如果有椭圆参数,绘制椭圆
if ellipse_params:
(ell_center, (width, height), angle) = ellipse_params
cx_ell, cy_ell = int(ell_center[0]), int(ell_center[1])
# 确定长轴和短轴
if width >= height:
# width 是长轴,height 是短轴
axes_major = width
axes_minor = height
major_angle = angle # 长轴角度就是 angle
minor_angle = angle + 90 # 短轴角度 = 长轴角度 + 90度
else:
# height 是长轴,width 是短轴
axes_major = height
axes_minor = width
major_angle = angle + 90 # 长轴角度 = width角度 + 90度
minor_angle = angle # 短轴角度就是 angle
# 使用 OpenCV 绘制椭圆(绿色,线宽2)
cv2.ellipse(img_cv,
(cx_ell, cy_ell), # 中心点
(int(width / 2), int(height / 2)), # 半宽、半高
angle, # 旋转角度(OpenCV需要原始angle
0, 360, # 起始和结束角度
(0, 255, 0), # 绿色 (RGB格式)
2) # 线宽
# 绘制椭圆中心点(红色)
cv2.circle(img_cv, (cx_ell, cy_ell), 3, (255, 0, 0), -1)
import math
# 绘制短轴(蓝色线条)
minor_length = axes_minor / 2
minor_angle_rad = math.radians(minor_angle)
dx_minor = minor_length * math.cos(minor_angle_rad)
dy_minor = minor_length * math.sin(minor_angle_rad)
pt1_minor = (int(cx_ell - dx_minor), int(cy_ell - dy_minor))
pt2_minor = (int(cx_ell + dx_minor), int(cy_ell + dy_minor))
cv2.line(img_cv, pt1_minor, pt2_minor, (0, 0, 255), 2) # 蓝色 (RGB格式)
else:
# 如果没有椭圆参数,绘制圆形(红色)
cv2.circle(img_cv, (cx, cy), radius, (0, 0, 255), 2)
cv2.circle(img_cv, (cx, cy), 2, (0, 0, 255), -1)
# 转换回 maix image
result_img = image.cv2image(img_cv, False, False)
# 定义颜色对象用于文字
try:
color_black = image.Color.from_rgb(0, 0, 0)
except AttributeError:
color_black = image.Color(0, 0, 0)
# D. 添加文字信息
FOCAL_LENGTH_PIX = 1900
d = (REAL_RADIUS_CM * FOCAL_LENGTH_PIX) / radius1 / 100.0
info_str = f"R:{radius} M:{method} D:{d:.2f}"
print(info_str)
# 计算文字位置,防止超出图片边界
r_outer = int(radius * 11.0) if radius else 100
text_y = cy - r_outer - 20 if cy > r_outer + 20 else cy + r_outer + 20
# 调用 draw_string
result_img.draw_string(0, 0, info_str, color=color_black, scale=1.0)
# 5. 保存结果图片
output_path = image_path.replace(".bmp", "_result.bmp")
output_path = image_path.replace(".jpg", "_result.jpg")
try:
result_img.save(output_path, quality=100)
print(f"[SUCCESS] 结果已保存至: {output_path}")
except Exception as e:
print(f"[ERROR] 保存图片失败: {e}")
if __name__ == "__main__":
# ================= 配置区域 =================
# 1. 设置要测试的图片路径
# 建议将图片放在与脚本同级目录,或者使用绝对路径
TARGET_IMAGE = "/root/phot/None_314_258_0_0041.bmp"
TARGET_DIR = "/root/phot" # 修改为你想要读取的目录路径
# 支持的图片格式
IMAGE_EXTENSIONS = ['.jpg', '.jpeg', '.png', '.bmp']
# ================= 执行区域 =================
if 'TARGET_DIR' in locals():
# 读取目录下所有图片文件,过滤掉 _result.jpg 后缀的文件
image_files = []
if os.path.exists(TARGET_DIR) and os.path.isdir(TARGET_DIR):
for filename in os.listdir(TARGET_DIR):
# 检查文件扩展名
if any(filename.lower().endswith(ext) for ext in IMAGE_EXTENSIONS):
# 过滤掉 _result.jpg 后缀的文件
if not filename.endswith('_result.jpg'):
filepath = os.path.join(TARGET_DIR, filename)
if os.path.isfile(filepath):
image_files.append(filepath)
# 按文件名排序(可选)
image_files.sort()
print(f"[INFO] 在目录 {TARGET_DIR} 中找到 {len(image_files)} 张图片")
# 处理每张图片
for img_path in image_files:
print(f"\n{'=' * 10} 开始处理: {img_path} {'=' * 10}")
run_offline_test(img_path)
else:
print(f"[ERROR] 目录不存在或不是有效目录: {TARGET_DIR}")
else:
run_offline_test(TARGET_IMAGE)
+5
View File
@@ -20,3 +20,8 @@
# 2.15.7 更新版本号 # 2.15.7 更新版本号
# 2.15.8 启动不加载预加载yolo # 2.15.8 启动不加载预加载yolo
# 2.15.9 20cm # 2.15.9 20cm
# 2.15.10 不保存图片
# 2.15.11 优化内存
# 2.15.12 优化算法
# 2.15.13 优化算法
# 2.15.14 优化算法
+1 -1
View File
@@ -4,6 +4,6 @@
应用版本号 应用版本号
每次 OTA 更新时,只需要更新这个文件中的版本号 每次 OTA 更新时,只需要更新这个文件中的版本号
""" """
VERSION = '2.15.9' VERSION = '2.15.14'
+20 -5
View File
@@ -570,11 +570,13 @@ def detect_circle_v3(frame, laser_point=None, img_cv=None):
# -- 3. 红色掩码:在循环外只算一次 # -- 3. 红色掩码:在循环外只算一次
mask_red = cv2.bitwise_or( mask_red = cv2.bitwise_or(
cv2.inRange(hsv, np.array([0, 50, 40]), np.array([10, 255, 255])), cv2.inRange(hsv, np.array([0, 30, 20]), np.array([12, 255, 255])),
cv2.inRange(hsv, np.array([170, 50, 40]), np.array([180, 255, 255])), cv2.inRange(hsv, np.array([168, 30, 20]), np.array([180, 255, 255])),
) )
kernel_red = cv2.getStructuringElement(cv2.MORPH_ELLIPSE, (5, 5)) kernel_red = cv2.getStructuringElement(cv2.MORPH_ELLIPSE, (5, 5))
mask_red = cv2.morphologyEx(mask_red, cv2.MORPH_CLOSE, kernel_red) mask_red = cv2.morphologyEx(mask_red, cv2.MORPH_CLOSE, kernel_red)
# 再加一次膨胀,加厚环状区域避免碎片化
mask_red = cv2.dilate(mask_red, kernel_red, iterations=1)
contours_red, _ = cv2.findContours(mask_red, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE) contours_red, _ = cv2.findContours(mask_red, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE)
# 预先把红色轮廓筛选成 (center, radius) 列表,后续直接查表 # 预先把红色轮廓筛选成 (center, radius) 列表,后续直接查表
red_candidates = [] red_candidates = []
@@ -583,7 +585,7 @@ def detect_circle_v3(frame, laser_point=None, img_cv=None):
if ar <= 10: if ar <= 10:
continue continue
pr = cv2.arcLength(cnt_r, True) pr = cv2.arcLength(cnt_r, True)
if pr <= 0 or (4 * np.pi * ar) / (pr * pr) <= 0.3: if pr <= 0 or (4 * np.pi * ar) / (pr * pr) <= 0.2:
continue continue
if len(cnt_r) >= 5: if len(cnt_r) >= 5:
(xr, yr), (wr, hr), _ = cv2.fitEllipse(cnt_r) (xr, yr), (wr, hr), _ = cv2.fitEllipse(cnt_r)
@@ -625,7 +627,11 @@ def detect_circle_v3(frame, laser_point=None, img_cv=None):
ddx = yellow_center[0] - rc["center"][0] ddx = yellow_center[0] - rc["center"][0]
ddy = yellow_center[1] - rc["center"][1] ddy = yellow_center[1] - rc["center"][1]
dist_centers = math.hypot(ddx, ddy) dist_centers = math.hypot(ddx, ddy)
if dist_centers < yellow_radius * 1.5 and rc["radius"] > yellow_radius * 0.7: max_dist = yellow_radius * 2.0
min_r = min(rc["radius"], yellow_radius)
max_r = max(rc["radius"], yellow_radius)
size_ratio = min_r / max_r if max_r > 0 else 0
if dist_centers < max_dist and size_ratio > 0.5:
if logger: if logger:
logger.info(f"[target] -> 找到匹配的红圈: 黄心({yellow_center}), " logger.info(f"[target] -> 找到匹配的红圈: 黄心({yellow_center}), "
f"红心({rc['center']}), 距离:{dist_centers:.1f}, " f"红心({rc['center']}), 距离:{dist_centers:.1f}, "
@@ -638,7 +644,16 @@ def detect_circle_v3(frame, laser_point=None, img_cv=None):
}) })
matched = True matched = True
break break
if not matched and logger: if not matched:
# 黄圈高置信度兜底:大且圆时跳过红圈验证
if area > 30 and circularity > 0.8:
valid_targets.append({
"center": yellow_center,
"radius": yellow_radius,
"ellipse": yellow_ellipse,
"area": area,
})
elif logger:
logger.debug("Debug -> 未找到匹配的红色圆圈,可能是误识别") logger.debug("Debug -> 未找到匹配的红色圆圈,可能是误识别")
logger.debug(f"[detect_circle_v3] step 4 fin {datetime.now()}") logger.debug(f"[detect_circle_v3] step 4 fin {datetime.now()}")
+16 -4
View File
@@ -41,6 +41,7 @@ class WiFiManager:
# WiFi 质量监测(后台线程) # WiFi 质量监测(后台线程)
self._wifi_quality_monitor_thread = None self._wifi_quality_monitor_thread = None
self._wifi_quality_stop_event = threading.Event() self._wifi_quality_stop_event = threading.Event()
self._wifi_quality_lock = threading.Lock()
self._last_wifi_rtt_ms = None # 最近一次测量的 RTT self._last_wifi_rtt_ms = None # 最近一次测量的 RTT
self._last_wifi_rssi_dbm = None # 最近一次测量的 RSSI self._last_wifi_rssi_dbm = None # 最近一次测量的 RSSI
@@ -542,7 +543,8 @@ class WiFiManager:
network_type_callback: 获取当前网络类型的回调函数 network_type_callback: 获取当前网络类型的回调函数
on_poor_quality_callback: WiFi质量差时的回调函数 on_poor_quality_callback: WiFi质量差时的回调函数
""" """
if self._wifi_quality_monitor_thread is not None: with self._wifi_quality_lock:
if self._wifi_quality_monitor_thread is not None and self._wifi_quality_monitor_thread.is_alive():
self.logger.warning("[WiFi Monitor] 监测线程已在运行") self.logger.warning("[WiFi Monitor] 监测线程已在运行")
return return
@@ -559,15 +561,25 @@ class WiFiManager:
def stop_quality_monitor(self): def stop_quality_monitor(self):
"""停止 WiFi 质量监测线程""" """停止 WiFi 质量监测线程"""
if self._wifi_quality_monitor_thread is None: with self._wifi_quality_lock:
t = self._wifi_quality_monitor_thread
if t is None:
return
if not t.is_alive():
self._wifi_quality_monitor_thread = None
return return
self._wifi_quality_stop_event.set() self._wifi_quality_stop_event.set()
try: try:
self._wifi_quality_monitor_thread.join(timeout=2.0) t.join(timeout=2.0)
except Exception as e: except Exception as e:
self.logger.error(f"[WiFi Monitor] 停止线程失败:{e}") self.logger.error(f"[WiFi Monitor] 停止线程失败:{e}")
finally:
with self._wifi_quality_lock:
if t is self._wifi_quality_monitor_thread:
if t.is_alive():
self.logger.warning("[WiFi Monitor] 线程未在超时内退出,保留引用防止重复创建")
else:
self._wifi_quality_monitor_thread = None self._wifi_quality_monitor_thread = None
self.logger.info("[WiFi Monitor] 已停止后台监测线程") self.logger.info("[WiFi Monitor] 已停止后台监测线程")