Compare commits
16
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
abbf30d7c0 | ||
|
|
5cf752bb3f | ||
|
|
6d8de56bfa | ||
|
|
aee1a92760 | ||
|
|
c34efed6f9 | ||
|
|
226394d3ed | ||
|
|
b169618b16 | ||
|
|
5ab4ef2944 | ||
|
|
577ff02c04 | ||
|
|
82d0008257 | ||
|
|
373eeb786a | ||
|
|
4500e62647 | ||
|
|
49a84e80e1 | ||
|
|
9654b79cec | ||
|
|
1ea8c64a40 | ||
|
|
9dd6fef6f8 |
@@ -1,6 +1,6 @@
|
||||
id: t11
|
||||
name: t11
|
||||
version: 2.15.9
|
||||
version: 2.15.18
|
||||
author: t11
|
||||
icon: ''
|
||||
desc: t11
|
||||
@@ -18,8 +18,6 @@ files:
|
||||
- laser_manager.py
|
||||
- logger_manager.py
|
||||
- main.py
|
||||
- model_270139.cvimodel
|
||||
- model_270139.mud
|
||||
- network.py
|
||||
- ota_curl.sh
|
||||
- ota_manager.py
|
||||
|
||||
@@ -308,8 +308,15 @@ LASER_COLOR = (0, 255, 0) # RGB颜色
|
||||
LASER_THICKNESS = 1
|
||||
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" # 照片存储目录
|
||||
MAX_IMAGES = 1000
|
||||
# Stage2 调试目录(默认 PHOTO_DIR/stage2_roi)内 JPEG 最多保留张数;None 表示与 MAX_IMAGES 相同
|
||||
|
||||
+2
-2
@@ -65,8 +65,8 @@ class LoggerManager:
|
||||
backup_count = config.LOG_BACKUP_COUNT
|
||||
|
||||
try:
|
||||
# 创建日志队列(无界队列)
|
||||
self._log_queue = queue.Queue(-1)
|
||||
# 创建日志队列(有界队列,防止内存泄漏;满时自动丢弃旧日志)
|
||||
self._log_queue = queue.Queue(maxsize=config.LOG_QUEUE_MAXSIZE)
|
||||
|
||||
# 确保日志文件所在的目录存在
|
||||
log_dir = os.path.dirname(log_file)
|
||||
|
||||
@@ -290,34 +290,33 @@ def cmd_str():
|
||||
last_avg_abs = 0
|
||||
|
||||
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
|
||||
if not pressure_buf:
|
||||
return
|
||||
t1_ms = time.ticks_ms()
|
||||
n = len(pressure_buf)
|
||||
avg = (pressure_sum / n) if n else 0
|
||||
avg_abs = (pressure_abs_sum / n) if n else 0
|
||||
# 一行输出:方便后处理画曲线;同时带上统计信息便于快速看波峰
|
||||
line = (
|
||||
f"[气压批量] reason={reason} "
|
||||
f"t0={pressure_t0_ms} t1={t1_ms} n={n} "
|
||||
f"min={pressure_min} max={pressure_max} avg={avg:.1f} avg_abs={avg_abs:.3f} "
|
||||
f"values={','.join(map(str, pressure_buf))}"
|
||||
f" convert value (kpa): {(max(pressure_buf, key=lambda x: x[1])[1] - last_avg_abs) / (5 - 2.5) * config.AIR_PRESSURE_HARDWARE_MAX:.1f}"
|
||||
)
|
||||
if logger:
|
||||
logger.debug(line)
|
||||
else:
|
||||
print(line)
|
||||
if config.AIR_PRESSURE_lOG:
|
||||
t1_ms = time.ticks_ms()
|
||||
n = len(pressure_buf)
|
||||
avg = (pressure_sum / n) if n else 0
|
||||
avg_abs = (pressure_abs_sum / n) if n else 0
|
||||
line = (
|
||||
f"[气压批量] reason={reason} "
|
||||
f"t0={pressure_t0_ms} t1={t1_ms} n={n} "
|
||||
f"min={pressure_min} max={pressure_max} avg={avg:.1f} avg_abs={avg_abs:.3f} "
|
||||
f"values={','.join(map(str, pressure_buf))}"
|
||||
f" convert value (kpa): {(max(pressure_buf, key=lambda x: x[1])[1] - last_avg_abs) / (5 - 2.5) * config.AIR_PRESSURE_HARDWARE_MAX:.1f}"
|
||||
)
|
||||
if logger:
|
||||
logger.debug(line)
|
||||
else:
|
||||
print(line)
|
||||
last_avg_abs = avg_abs
|
||||
# 无论是否记录日志,都必须清空 buffer,否则内存泄漏
|
||||
pressure_buf = []
|
||||
pressure_sum = 0
|
||||
pressure_abs_sum = 0
|
||||
pressure_min = 4095
|
||||
pressure_max = 0
|
||||
pressure_t0_ms = None
|
||||
last_avg_abs = avg_abs
|
||||
|
||||
# 主循环:检测扳机触发 → 拍照 → 分析 → 上报
|
||||
while not app.need_exit():
|
||||
|
||||
+105
-26
@@ -72,6 +72,10 @@ class NetworkManager:
|
||||
self._raw_line_data = []
|
||||
self._manual_trigger_flag = False
|
||||
|
||||
# 限制并发命令线程数
|
||||
self._cmd_thread_lock = threading.Lock()
|
||||
self._cmd_thread_count = 0
|
||||
|
||||
# 网络类型状态
|
||||
self._network_type = None # "wifi" 或 "4G" 或 None
|
||||
# 本次上电曾因 WiFi 质量差切换到 4G 后,直至关机不再改回 WiFi
|
||||
@@ -165,11 +169,15 @@ class NetworkManager:
|
||||
self._password = password
|
||||
|
||||
def _enqueue(self, item, high=False):
|
||||
"""线程安全地加入队列(内部方法)"""
|
||||
"""线程安全地加入队列(内部方法),队列满时丢弃最旧消息"""
|
||||
with self._queue_lock:
|
||||
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)
|
||||
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._send_event.set()
|
||||
|
||||
@@ -198,6 +206,29 @@ class NetworkManager:
|
||||
"""获取队列锁(用于with语句)"""
|
||||
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):
|
||||
@@ -593,6 +624,11 @@ class NetworkManager:
|
||||
password = inner_data.get("password")
|
||||
ota_res_url = inner_data.get("url")
|
||||
try:
|
||||
for _f in ("/etc/wpa_supplicant.conf", "/boot/wpa_supplicant.conf", "/boot/wifi.ssid", "/boot/wifi.pass"):
|
||||
try:
|
||||
os.remove(_f)
|
||||
except OSError:
|
||||
pass
|
||||
w = network.wifi.Wifi()
|
||||
e = w.connect(ssid, password, wait=True, timeout=15)
|
||||
err.check_raise(e, "connect wifi failed")
|
||||
@@ -627,6 +663,48 @@ class NetworkManager:
|
||||
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:
|
||||
for _f in ("/etc/wpa_supplicant.conf", "/boot/wpa_supplicant.conf", "/boot/wifi.ssid", "/boot/wifi.pass"):
|
||||
try:
|
||||
os.remove(_f)
|
||||
except OSError:
|
||||
pass
|
||||
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,
|
||||
)
|
||||
self._session_force_4g = False
|
||||
self.disconnect_server()
|
||||
self._tcp_connected = False
|
||||
self._network_type = None
|
||||
self.logger.info("[conn wifi] WiFi已连接,等待主循环重新登录")
|
||||
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):
|
||||
"""线程安全地将消息加入队列(公共方法)"""
|
||||
self._enqueue((msg_type, data_dict), high)
|
||||
@@ -1696,8 +1774,6 @@ class NetworkManager:
|
||||
|
||||
def tcp_main(self):
|
||||
"""TCP 主通信循环:登录、心跳、处理指令、发送数据"""
|
||||
import _thread
|
||||
|
||||
self.logger.info("[NET] TCP主线程启动")
|
||||
|
||||
send_hartbeat_fail_count = 0
|
||||
@@ -1933,8 +2009,7 @@ class NetworkManager:
|
||||
self.logger.info(f"[IMAGE_UPLOAD] 准备上传: {target_image} -> {key}")
|
||||
|
||||
# 在新线程中执行上传,避免阻塞主循环
|
||||
import _thread
|
||||
_thread.start_new_thread(
|
||||
self._spawn_cmd_thread(
|
||||
self._upload_image_file,
|
||||
(target_image, upload_url, upload_token, key, shoot_id, outlink)
|
||||
)
|
||||
@@ -1962,8 +2037,7 @@ class NetworkManager:
|
||||
else:
|
||||
self.logger.info(f"[LOG_UPLOAD] 收到日志上传命令,key: {key}")
|
||||
# 在新线程中执行上传,避免阻塞主循环
|
||||
import _thread
|
||||
_thread.start_new_thread(
|
||||
self._spawn_cmd_thread(
|
||||
self._upload_log_file_v2,
|
||||
(upload_url, upload_token, key, outlink, include_rotated, max_files,
|
||||
archive_format)
|
||||
@@ -2033,6 +2107,7 @@ class NetworkManager:
|
||||
battery_data = {
|
||||
"battery": battery_percent,
|
||||
"voltage": round(float(voltage), 3),
|
||||
"netType": self.network_type,
|
||||
}
|
||||
self.safe_enqueue(battery_data, 2)
|
||||
self.logger.info(f"电量上报: {battery_percent}%")
|
||||
@@ -2078,7 +2153,7 @@ class NetworkManager:
|
||||
if mode == "4g":
|
||||
ota_manager._set_ota_url(ota_url) # 记录 OTA URL,供命令7使用
|
||||
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"
|
||||
if not ssid or not password:
|
||||
self.logger.error("ota wifi mode requires ssid and password")
|
||||
@@ -2087,8 +2162,8 @@ class NetworkManager:
|
||||
self.logger.info(f"ssid: {ssid}")
|
||||
self.logger.info(f"password: {password}")
|
||||
ota_manager._start_update_thread()
|
||||
_thread.start_new_thread(ota_manager.handle_wifi_and_update,
|
||||
(ssid, password, ota_url))
|
||||
self._spawn_cmd_thread(ota_manager.handle_wifi_and_update,
|
||||
(ssid, password, ota_url))
|
||||
elif inner_cmd == 6:
|
||||
try:
|
||||
ip = os.popen(
|
||||
@@ -2147,21 +2222,23 @@ class NetworkManager:
|
||||
2)
|
||||
else:
|
||||
self.logger.info(f"[LOG_UPLOAD] 收到日志上传命令,目标URL: {upload_url}")
|
||||
# 在新线程中执行上传,避免阻塞主循环
|
||||
import _thread
|
||||
_thread.start_new_thread(
|
||||
self._upload_log_file,
|
||||
(upload_url, wifi_ssid, wifi_password, include_rotated, max_files,
|
||||
archive_format)
|
||||
)
|
||||
# 在新线程中执行上传,避免阻塞主循环
|
||||
self._spawn_cmd_thread(
|
||||
self._upload_log_file,
|
||||
(upload_url, wifi_ssid, wifi_password, include_rotated, max_files,
|
||||
archive_format)
|
||||
)
|
||||
elif inner_cmd == 200:
|
||||
self.logger.info("[LASER] cmd200 在后台线程执行检测")
|
||||
import _thread
|
||||
_thread.start_new_thread(self._cmd200_detect_laser, ())
|
||||
self._spawn_cmd_thread(self._cmd200_detect_laser, ())
|
||||
elif inner_cmd == 300:
|
||||
self.logger.info("[New Ota] cmd300 在后台线程执行OTA")
|
||||
import _thread
|
||||
_thread.start_new_thread(self._cmd300_ota, (data_obj,))
|
||||
self._spawn_cmd_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
|
||||
self.logger.info(f"[NET] body={body}, {time.time()}")
|
||||
else:
|
||||
@@ -2190,12 +2267,14 @@ class NetworkManager:
|
||||
msg_type, data_dict = item
|
||||
pkt = self._netcore.make_packet(msg_type, data_dict)
|
||||
if not self.tcp_send_raw(pkt):
|
||||
# 发送失败:将消息放回队首,触发重连(避免丢消息)
|
||||
# 发送失败:将消息放回队首(队列满则丢弃)
|
||||
with self.get_queue_lock():
|
||||
if item_is_high:
|
||||
self._high_send_queue.insert(0, item)
|
||||
if len(self._high_send_queue) < config.MAX_SEND_QUEUE_SIZE:
|
||||
self._high_send_queue.insert(0, item)
|
||||
else:
|
||||
self._normal_send_queue.insert(0, item)
|
||||
if len(self._normal_send_queue) < config.MAX_SEND_QUEUE_SIZE:
|
||||
self._normal_send_queue.insert(0, item)
|
||||
self._tcp_connected = False
|
||||
try:
|
||||
self.disconnect_server()
|
||||
@@ -2262,7 +2341,7 @@ class NetworkManager:
|
||||
|
||||
self._tcp_connected = False
|
||||
self.logger.error("连接异常,2秒后重连...")
|
||||
time.sleep_ms(2000)
|
||||
time.sleep_ms(200)
|
||||
|
||||
except Exception as e:
|
||||
# TCP主循环的顶层异常捕获,防止线程静默退出
|
||||
@@ -2270,7 +2349,7 @@ class NetworkManager:
|
||||
import traceback
|
||||
self.logger.error(traceback.format_exc())
|
||||
self._tcp_connected = False
|
||||
time.sleep_ms(5000) # 等待5秒后重试连接
|
||||
time.sleep_ms(500) # 等待5秒后重试连接
|
||||
|
||||
|
||||
# 创建全局单例实例
|
||||
|
||||
@@ -0,0 +1,635 @@
|
||||
#!/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-12度(接近0度的红色)
|
||||
# 放宽S/V阈值:S>=30, V>=20 以捕获淡红/暗红
|
||||
lower_red1 = np.array([0, 30, 20])
|
||||
upper_red1 = np.array([12, 255, 255])
|
||||
mask_red1 = cv2.inRange(hsv, lower_red1, upper_red1)
|
||||
|
||||
# 红色范围2: 168-180度(接近180度的红色)
|
||||
lower_red2 = np.array([168, 30, 20])
|
||||
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)
|
||||
|
||||
# 形态学操作:先CLOSE填充空洞,再DILATE加厚环状区域
|
||||
kernel_red = cv2.getStructuringElement(cv2.MORPH_ELLIPSE, (5, 5))
|
||||
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)
|
||||
|
||||
red_pixel_count = np.sum(mask_red > 0)
|
||||
print(f"Debug -> 红色掩码: {red_pixel_count} 像素, {len(contours_red)} 个轮廓")
|
||||
|
||||
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
|
||||
|
||||
# 环状轮廓圆度可能偏低,放宽到0.2
|
||||
print(f"Debug -> 红轮廓: 面积={area_red:.1f}, 圆度={circularity_red:.2f}" +
|
||||
f" (面积>15={area_red > 15}, 圆度>0.2={circularity_red > 0.2})")
|
||||
if area_red > 15 and circularity_red > 0.2:
|
||||
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)
|
||||
|
||||
max_distance = yellow_radius * 2.0
|
||||
min_r = min(red_radius, yellow_radius)
|
||||
max_r = max(red_radius, yellow_radius)
|
||||
size_ratio = min_r / max_r if max_r > 0 else 0
|
||||
print(f"Debug -> 圆心距={distance:.1f}(阈值={max_distance:.1f}), "
|
||||
f"大小比={size_ratio:.2f}(阈值=0.5), "
|
||||
f"距离OK={distance < max_distance}, 大小OK={size_ratio > 0.5}")
|
||||
|
||||
# 允许红圈在黄圈外侧或内侧,只要大小相近(较小/较大 >= 0.5)
|
||||
if distance < max_distance and size_ratio > 0.5:
|
||||
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:
|
||||
# 如果黄圈非常可靠(大且圆),在没有红圈验证时仍接受
|
||||
if area > 30 and circularity > 0.85:
|
||||
print(f"[target] -> 黄圈高置信度(面积:{area:.0f}, 圆度:{circularity:.2f}),跳过红圈验证直接接受")
|
||||
valid_targets.append({
|
||||
'center': yellow_center,
|
||||
'radius': yellow_radius,
|
||||
'ellipse': yellow_ellipse,
|
||||
'area': area
|
||||
})
|
||||
else:
|
||||
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 filename.endswith('no_target.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)
|
||||
@@ -20,3 +20,12 @@
|
||||
# 2.15.7 更新版本号
|
||||
# 2.15.8 启动不加载预加载yolo
|
||||
# 2.15.9 20cm
|
||||
# 2.15.10 不保存图片
|
||||
# 2.15.11 优化内存
|
||||
# 2.15.12 优化算法
|
||||
# 2.15.13 优化算法
|
||||
# 2.15.14 优化算法
|
||||
# 2.15.15 优化wifi连接
|
||||
# 2.15.16 修复wifi连接问题
|
||||
# 2.15.17 修复wifi连接问题
|
||||
# 2.15.18 wifi连接成功重新登录
|
||||
+1
-1
@@ -4,6 +4,6 @@
|
||||
应用版本号
|
||||
每次 OTA 更新时,只需要更新这个文件中的版本号
|
||||
"""
|
||||
VERSION = '2.15.9'
|
||||
VERSION = '2.15.18'
|
||||
|
||||
|
||||
|
||||
@@ -570,11 +570,13 @@ def detect_circle_v3(frame, laser_point=None, img_cv=None):
|
||||
|
||||
# -- 3. 红色掩码:在循环外只算一次
|
||||
mask_red = cv2.bitwise_or(
|
||||
cv2.inRange(hsv, np.array([0, 50, 40]), np.array([10, 255, 255])),
|
||||
cv2.inRange(hsv, np.array([170, 50, 40]), np.array([180, 255, 255])),
|
||||
cv2.inRange(hsv, np.array([0, 30, 20]), np.array([12, 255, 255])),
|
||||
cv2.inRange(hsv, np.array([168, 30, 20]), np.array([180, 255, 255])),
|
||||
)
|
||||
kernel_red = cv2.getStructuringElement(cv2.MORPH_ELLIPSE, (5, 5))
|
||||
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)
|
||||
# 预先把红色轮廓筛选成 (center, radius) 列表,后续直接查表
|
||||
red_candidates = []
|
||||
@@ -583,7 +585,7 @@ def detect_circle_v3(frame, laser_point=None, img_cv=None):
|
||||
if ar <= 10:
|
||||
continue
|
||||
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
|
||||
if len(cnt_r) >= 5:
|
||||
(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]
|
||||
ddy = yellow_center[1] - rc["center"][1]
|
||||
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:
|
||||
logger.info(f"[target] -> 找到匹配的红圈: 黄心({yellow_center}), "
|
||||
f"红心({rc['center']}), 距离:{dist_centers:.1f}, "
|
||||
@@ -638,8 +644,17 @@ def detect_circle_v3(frame, laser_point=None, img_cv=None):
|
||||
})
|
||||
matched = True
|
||||
break
|
||||
if not matched and logger:
|
||||
logger.debug("Debug -> 未找到匹配的红色圆圈,可能是误识别")
|
||||
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(f"[detect_circle_v3] step 4 fin {datetime.now()}")
|
||||
|
||||
|
||||
@@ -41,6 +41,7 @@ class WiFiManager:
|
||||
# WiFi 质量监测(后台线程)
|
||||
self._wifi_quality_monitor_thread = None
|
||||
self._wifi_quality_stop_event = threading.Event()
|
||||
self._wifi_quality_lock = threading.Lock()
|
||||
self._last_wifi_rtt_ms = None # 最近一次测量的 RTT
|
||||
self._last_wifi_rssi_dbm = None # 最近一次测量的 RSSI
|
||||
|
||||
@@ -238,7 +239,6 @@ class WiFiManager:
|
||||
old_conf = _read_text(conf_path)
|
||||
old_boot_ssid = _read_text(ssid_file)
|
||||
old_boot_pass = _read_text(pass_file)
|
||||
old_boot_wpa = _read_text(boot_wpa_path) if os.path.exists(boot_wpa_path) else None
|
||||
|
||||
try:
|
||||
try:
|
||||
@@ -250,9 +250,13 @@ class WiFiManager:
|
||||
_write_text(conf_path, full_conf)
|
||||
except Exception:
|
||||
pass
|
||||
_write_text(boot_wpa_path, full_conf)
|
||||
# 删除 wpa_supplicant.conf,让 S30wifi 回退读 ssid/pass
|
||||
try:
|
||||
if os.path.exists(boot_wpa_path):
|
||||
os.remove(boot_wpa_path)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# 仍写入 ssid/pass,便于其它脚本/人工查看;S30wifi 优先使用 wpa_supplicant.conf
|
||||
_write_text(ssid_file, ssid.strip())
|
||||
_write_text(pass_file, password.strip())
|
||||
|
||||
@@ -292,7 +296,6 @@ class WiFiManager:
|
||||
if not persist:
|
||||
# 不持久化:把 /boot 恢复成旧值(不重启,当前连接保持不变)
|
||||
_restore_boot(old_boot_ssid, old_boot_pass)
|
||||
_restore_boot_wpa(old_boot_wpa)
|
||||
self.logger.info("[WIFI] 网络验证通过,但按 persist=False 回滚 /boot 凭证(不重启)")
|
||||
else:
|
||||
self.logger.info("[WIFI] 网络验证通过,/boot 凭证已保留(持久化)")
|
||||
@@ -306,7 +309,6 @@ class WiFiManager:
|
||||
except Exception as e:
|
||||
# 失败:回滚 /boot 和 /etc,重启 WiFi 恢复旧网络
|
||||
_restore_boot(old_boot_ssid, old_boot_pass)
|
||||
_restore_boot_wpa(old_boot_wpa)
|
||||
try:
|
||||
if old_conf is not None:
|
||||
_write_text(conf_path, old_conf)
|
||||
@@ -351,7 +353,11 @@ class WiFiManager:
|
||||
else:
|
||||
full_conf = build_sta_conf_open(ssid)
|
||||
_write_text(conf_path, full_conf)
|
||||
_write_text(boot_wpa_path, full_conf)
|
||||
try:
|
||||
if os.path.exists(boot_wpa_path):
|
||||
os.remove(boot_wpa_path)
|
||||
except Exception:
|
||||
pass
|
||||
except ValueError as e:
|
||||
return False, str(e)
|
||||
except Exception as e:
|
||||
@@ -542,34 +548,45 @@ class WiFiManager:
|
||||
network_type_callback: 获取当前网络类型的回调函数
|
||||
on_poor_quality_callback: WiFi质量差时的回调函数
|
||||
"""
|
||||
if self._wifi_quality_monitor_thread is not None:
|
||||
self.logger.warning("[WiFi Monitor] 监测线程已在运行")
|
||||
return
|
||||
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] 监测线程已在运行")
|
||||
return
|
||||
|
||||
self._network_type_callback = network_type_callback
|
||||
self._on_poor_quality_callback = on_poor_quality_callback
|
||||
self._wifi_quality_stop_event.clear()
|
||||
self._wifi_quality_monitor_thread = threading.Thread(
|
||||
target=self._quality_monitor_loop,
|
||||
daemon=True,
|
||||
name="wifi_quality_monitor"
|
||||
)
|
||||
self._wifi_quality_monitor_thread.start()
|
||||
self.logger.info("[WiFi Monitor] 已启动后台监测线程")
|
||||
self._network_type_callback = network_type_callback
|
||||
self._on_poor_quality_callback = on_poor_quality_callback
|
||||
self._wifi_quality_stop_event.clear()
|
||||
self._wifi_quality_monitor_thread = threading.Thread(
|
||||
target=self._quality_monitor_loop,
|
||||
daemon=True,
|
||||
name="wifi_quality_monitor"
|
||||
)
|
||||
self._wifi_quality_monitor_thread.start()
|
||||
self.logger.info("[WiFi Monitor] 已启动后台监测线程")
|
||||
|
||||
def stop_quality_monitor(self):
|
||||
"""停止 WiFi 质量监测线程"""
|
||||
if self._wifi_quality_monitor_thread is None:
|
||||
return
|
||||
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
|
||||
|
||||
self._wifi_quality_stop_event.set()
|
||||
try:
|
||||
self._wifi_quality_monitor_thread.join(timeout=2.0)
|
||||
t.join(timeout=2.0)
|
||||
except Exception as e:
|
||||
self.logger.error(f"[WiFi Monitor] 停止线程失败:{e}")
|
||||
finally:
|
||||
self._wifi_quality_monitor_thread = None
|
||||
self.logger.info("[WiFi Monitor] 已停止后台监测线程")
|
||||
|
||||
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.logger.info("[WiFi Monitor] 已停止后台监测线程")
|
||||
|
||||
def _quality_monitor_loop(self):
|
||||
"""
|
||||
|
||||
Reference in New Issue
Block a user