Compare commits
27
Commits
860f9c84c3
..
2.16.0
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1fee464924 | ||
|
|
06994c5905 | ||
|
|
23755f48ae | ||
|
|
5f509488c5 | ||
|
|
c0bb245c8c | ||
|
|
9cfc871645 | ||
|
|
27f96d8bce | ||
|
|
80e780b931 | ||
|
|
3683033abf | ||
|
|
f0df9ad915 | ||
|
|
3fcd38f417 | ||
|
|
abbf30d7c0 | ||
|
|
5cf752bb3f | ||
|
|
6d8de56bfa | ||
|
|
aee1a92760 | ||
|
|
c34efed6f9 | ||
|
|
226394d3ed | ||
|
|
b169618b16 | ||
|
|
5ab4ef2944 | ||
|
|
577ff02c04 | ||
|
|
82d0008257 | ||
|
|
373eeb786a | ||
|
|
4500e62647 | ||
|
|
49a84e80e1 | ||
|
|
9654b79cec | ||
|
|
1ea8c64a40 | ||
|
|
9dd6fef6f8 |
@@ -1,6 +1,6 @@
|
|||||||
id: t11
|
id: t11
|
||||||
name: t11
|
name: t11
|
||||||
version: 2.15.9
|
version: 2.16.4
|
||||||
author: t11
|
author: t11
|
||||||
icon: ''
|
icon: ''
|
||||||
desc: t11
|
desc: t11
|
||||||
@@ -18,8 +18,6 @@ files:
|
|||||||
- laser_manager.py
|
- laser_manager.py
|
||||||
- logger_manager.py
|
- logger_manager.py
|
||||||
- main.py
|
- main.py
|
||||||
- model_270139.cvimodel
|
|
||||||
- model_270139.mud
|
|
||||||
- network.py
|
- network.py
|
||||||
- ota_curl.sh
|
- ota_curl.sh
|
||||||
- ota_manager.py
|
- ota_manager.py
|
||||||
|
|||||||
+7
-6
@@ -76,10 +76,11 @@ class ATClient:
|
|||||||
"""
|
"""
|
||||||
expect_b = expect.encode() if isinstance(expect, str) else expect
|
expect_b = expect.encode() if isinstance(expect, str) else expect
|
||||||
with self._cmd_lock:
|
with self._cmd_lock:
|
||||||
# 初始化等待
|
with self._q_lock:
|
||||||
self._waiting = True
|
# 初始化等待
|
||||||
self._expect = expect_b
|
self._waiting = True
|
||||||
self._resp = b""
|
self._expect = expect_b
|
||||||
|
self._resp = b""
|
||||||
|
|
||||||
# 发送
|
# 发送
|
||||||
if cmd:
|
if cmd:
|
||||||
@@ -300,8 +301,8 @@ class ATClient:
|
|||||||
if len(self._rx) > 512 * 1024:
|
if len(self._rx) > 512 * 1024:
|
||||||
self._rx = self._rx[-256 * 1024:]
|
self._rx = self._rx[-256 * 1024:]
|
||||||
else:
|
else:
|
||||||
if len(self._rx) > 16384:
|
if len(self._rx) > 32768:
|
||||||
self._rx = self._rx[-4096:]
|
self._rx = self._rx[-16384:]
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -24,7 +24,7 @@ TRIANGLE_DETECT_SCALE = 0.4
|
|||||||
# SERVER_IP = "stcp.shelingxingqiu.com"
|
# SERVER_IP = "stcp.shelingxingqiu.com"
|
||||||
SERVER_IP = "www.shelingxingqiu.com"
|
SERVER_IP = "www.shelingxingqiu.com"
|
||||||
SERVER_PORT = 50005
|
SERVER_PORT = 50005
|
||||||
HEARTBEAT_INTERVAL = 15 # 心跳间隔(秒)
|
HEARTBEAT_INTERVAL = 5 # 心跳间隔(秒)
|
||||||
|
|
||||||
# WiFi 质量评估(开机先尝试 WiFi;质量差且 4G 可用则切到 4G,本次上电直至关机锁定 4G)
|
# WiFi 质量评估(开机先尝试 WiFi;质量差且 4G 可用则切到 4G,本次上电直至关机锁定 4G)
|
||||||
WIFI_QUALITY_RTT_SAMPLES = 3 # 到业务服务器 TCP 建连耗时采样次数,取中位数
|
WIFI_QUALITY_RTT_SAMPLES = 3 # 到业务服务器 TCP 建连耗时采样次数,取中位数
|
||||||
@@ -308,8 +308,16 @@ 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=不保存)
|
||||||
|
SAVE_IMAGE_ON_FAILURE = True # 检测失败时是否强制保存图像(供调试测试用)
|
||||||
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
@@ -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)
|
||||||
|
|||||||
@@ -120,9 +120,9 @@ def cmd_str():
|
|||||||
|
|
||||||
# ==================== 第二阶段:软件初始化 ====================
|
# ==================== 第二阶段:软件初始化 ====================
|
||||||
|
|
||||||
# 1. 初始化日志系统
|
# 1. 初始化日志系统(WARNING级别,不打印/写入INFO和DEBUG日志,提高执行流畅度)
|
||||||
import logging
|
import logging
|
||||||
logger_manager.init_logging(log_level=logging.DEBUG)
|
logger_manager.init_logging(log_level=logging.WARNING)
|
||||||
logger = logger_manager.logger
|
logger = logger_manager.logger
|
||||||
|
|
||||||
# 补充:因为初始化的时候,激光会亮,先关了它
|
# 补充:因为初始化的时候,激光会亮,先关了它
|
||||||
@@ -245,8 +245,8 @@ def cmd_str():
|
|||||||
# 4. 初始化设备ID(network_manager 内部会自动设置 device_id 和 password)
|
# 4. 初始化设备ID(network_manager 内部会自动设置 device_id 和 password)
|
||||||
network_manager.read_device_id()
|
network_manager.read_device_id()
|
||||||
|
|
||||||
# 5. 创建照片存储目录(如果启用图像保存)
|
# 5. 创建照片存储目录(如果启用图像保存或检测失败时强制保存)
|
||||||
if config.SAVE_IMAGE_ENABLED:
|
if config.SAVE_IMAGE_ENABLED or getattr(config, "SAVE_IMAGE_ON_FAILURE", False):
|
||||||
photo_dir = config.PHOTO_DIR
|
photo_dir = config.PHOTO_DIR
|
||||||
if photo_dir not in os.listdir("/root"):
|
if photo_dir not in os.listdir("/root"):
|
||||||
try:
|
try:
|
||||||
@@ -278,46 +278,45 @@ def cmd_str():
|
|||||||
logger.info("系统准备完成...")
|
logger.info("系统准备完成...")
|
||||||
|
|
||||||
last_adc_trigger = 0
|
last_adc_trigger = 0
|
||||||
|
# 读取一次ADC初始值,防止开机时传感器已有压力导致误触发
|
||||||
|
try:
|
||||||
|
last_adc_val = hardware_manager.adc_obj.read()
|
||||||
|
except Exception:
|
||||||
|
last_adc_val = 0
|
||||||
|
peak_adc_val = 0 # 当前周期内的压力峰值
|
||||||
# 气压采样:减少日志频率(每 N 个点输出一条),避免 logger.debug 拖慢采样
|
# 气压采样:减少日志频率(每 N 个点输出一条),避免 logger.debug 拖慢采样
|
||||||
PRESSURE_BATCH_SIZE = 100
|
PRESSURE_BATCH_SIZE = 100
|
||||||
|
|
||||||
pressure_buf = []
|
pressure_buf = []
|
||||||
pressure_sum = 0
|
pressure_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 = 0
|
|
||||||
|
|
||||||
def _flush_pressure_buf(reason: str):
|
def _flush_pressure_buf(reason: str):
|
||||||
if not config.AIR_PRESSURE_lOG:
|
nonlocal pressure_buf, pressure_sum, pressure_min, pressure_max, pressure_t0_ms, logger
|
||||||
return
|
|
||||||
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
|
||||||
t1_ms = time.ticks_ms()
|
if config.AIR_PRESSURE_lOG:
|
||||||
n = len(pressure_buf)
|
t1_ms = time.ticks_ms()
|
||||||
avg = (pressure_sum / n) if n else 0
|
n = len(pressure_buf)
|
||||||
avg_abs = (pressure_abs_sum / n) if n else 0
|
avg = (pressure_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} "
|
f"min={pressure_min} max={pressure_max} avg={avg:.1f} "
|
||||||
f"min={pressure_min} max={pressure_max} avg={avg:.1f} avg_abs={avg_abs:.3f} "
|
f"values={','.join(map(str, pressure_buf))}"
|
||||||
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)
|
||||||
if logger:
|
else:
|
||||||
logger.debug(line)
|
print(line)
|
||||||
else:
|
# 无论是否记录日志,都必须清空 buffer,否则内存泄漏
|
||||||
print(line)
|
|
||||||
pressure_buf = []
|
pressure_buf = []
|
||||||
pressure_sum = 0
|
pressure_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():
|
||||||
@@ -352,12 +351,10 @@ def cmd_str():
|
|||||||
if network_manager.manual_trigger_flag:
|
if network_manager.manual_trigger_flag:
|
||||||
network_manager.clear_manual_trigger()
|
network_manager.clear_manual_trigger()
|
||||||
adc_val = config.ADC_TRIGGER_THRESHOLD + 1
|
adc_val = config.ADC_TRIGGER_THRESHOLD + 1
|
||||||
adc_abs_val = 10
|
|
||||||
if logger:
|
if logger:
|
||||||
logger.info("[TEST] TCP命令触发射箭")
|
logger.info("[TEST] TCP命令触发射箭")
|
||||||
else:
|
else:
|
||||||
adc_val = hardware_manager.adc_obj.read()
|
adc_val = hardware_manager.adc_obj.read()
|
||||||
adc_abs_val = hardware_manager.adc_obj.read_vol()
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger = logger_manager.logger
|
logger = logger_manager.logger
|
||||||
if logger:
|
if logger:
|
||||||
@@ -368,24 +365,29 @@ def cmd_str():
|
|||||||
# ====== 气压采样缓存(每次循环都记录,批量输出日志)======
|
# ====== 气压采样缓存(每次循环都记录,批量输出日志)======
|
||||||
if pressure_t0_ms is None:
|
if pressure_t0_ms is None:
|
||||||
pressure_t0_ms = current_time
|
pressure_t0_ms = current_time
|
||||||
pressure_buf.append((adc_val, adc_abs_val))
|
pressure_buf.append(adc_val)
|
||||||
pressure_sum += adc_val
|
pressure_sum += adc_val
|
||||||
pressure_abs_sum += adc_abs_val
|
|
||||||
if adc_val < pressure_min:
|
if adc_val < pressure_min:
|
||||||
pressure_min = adc_val
|
pressure_min = adc_val
|
||||||
if adc_val > pressure_max:
|
if adc_val > pressure_max:
|
||||||
pressure_max = adc_val
|
pressure_max = adc_val
|
||||||
if len(pressure_buf) >= PRESSURE_BATCH_SIZE:
|
if len(pressure_buf) >= PRESSURE_BATCH_SIZE:
|
||||||
_flush_pressure_buf("batch")
|
_flush_pressure_buf("batch")
|
||||||
# if adc_val >= 2000:
|
# 峰值检测:压力从峰值下降时触发,确保捕获到最大冲击时刻
|
||||||
# print(f"adc :{adc_val}")
|
if adc_val > peak_adc_val:
|
||||||
if adc_val >= config.ADC_TRIGGER_THRESHOLD:
|
peak_adc_val = adc_val # 更新峰值
|
||||||
|
if (peak_adc_val >= config.ADC_TRIGGER_THRESHOLD
|
||||||
|
and adc_val < peak_adc_val
|
||||||
|
and last_adc_val >= peak_adc_val):
|
||||||
|
# 封顶后下降沿触发:peak是最大值,当前值开始下降,且上次值还在peak位置
|
||||||
hardware_manager.start_idle_timer() # 重新计时
|
hardware_manager.start_idle_timer() # 重新计时
|
||||||
diff_ms = current_time - last_adc_trigger
|
diff_ms = current_time - last_adc_trigger
|
||||||
if diff_ms < 3000:
|
if diff_ms < 3000:
|
||||||
logger.info(f"[MAIN] 扳机触发过于频繁, {diff_ms}ms")
|
peak_adc_val = 0 # 去抖期间重置峰值
|
||||||
|
time.sleep_ms(5)
|
||||||
continue
|
continue
|
||||||
last_adc_trigger = current_time
|
last_adc_trigger = current_time
|
||||||
|
peak_adc_val = 0 # 触发后重置峰值
|
||||||
# 触发前先把缓存刷出来,避免波形被长耗时处理截断
|
# 触发前先把缓存刷出来,避免波形被长耗时处理截断
|
||||||
_flush_pressure_buf("before_trigger")
|
_flush_pressure_buf("before_trigger")
|
||||||
|
|
||||||
@@ -404,10 +406,9 @@ def cmd_str():
|
|||||||
try:
|
try:
|
||||||
camera_manager.show(camera_manager.read_frame())
|
camera_manager.show(camera_manager.read_frame())
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger = logger_manager.logger
|
pass
|
||||||
if logger:
|
|
||||||
logger.error(f"[MAIN] 显示异常: {e}")
|
|
||||||
time.sleep_ms(5)
|
time.sleep_ms(5)
|
||||||
|
last_adc_val = adc_val
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
# 主循环的顶层异常捕获,防止程序静默退出
|
# 主循环的顶层异常捕获,防止程序静默退出
|
||||||
|
|||||||
+158
-34
@@ -18,7 +18,7 @@ import socket
|
|||||||
import config
|
import config
|
||||||
|
|
||||||
from hardware import hardware_manager
|
from hardware import hardware_manager
|
||||||
from power import get_bus_voltage, voltage_to_percent
|
from power import get_bus_voltage, voltage_to_percent, is_charging
|
||||||
from logger_manager import logger_manager
|
from logger_manager import logger_manager
|
||||||
from wifi import wifi_manager
|
from wifi import wifi_manager
|
||||||
import subprocess
|
import subprocess
|
||||||
@@ -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):
|
||||||
@@ -593,6 +624,11 @@ class NetworkManager:
|
|||||||
password = inner_data.get("password")
|
password = inner_data.get("password")
|
||||||
ota_res_url = inner_data.get("url")
|
ota_res_url = inner_data.get("url")
|
||||||
try:
|
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()
|
w = network.wifi.Wifi()
|
||||||
e = w.connect(ssid, password, wait=True, timeout=15)
|
e = w.connect(ssid, password, wait=True, timeout=15)
|
||||||
err.check_raise(e, "connect wifi failed")
|
err.check_raise(e, "connect wifi failed")
|
||||||
@@ -627,6 +663,50 @@ 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")
|
||||||
|
# 停止旧的WiFi质量监测(无论当前是WiFi还是4G连接)
|
||||||
|
self._stop_wifi_quality_monitor()
|
||||||
|
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):
|
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)
|
||||||
@@ -643,7 +723,7 @@ class NetworkManager:
|
|||||||
if self._network_type == "wifi":
|
if self._network_type == "wifi":
|
||||||
return self._check_wifi_connection()
|
return self._check_wifi_connection()
|
||||||
elif self._network_type == "4g":
|
elif self._network_type == "4g":
|
||||||
return True # 4G连接状态由AT命令维护
|
return self._check_4g_connection()
|
||||||
return False
|
return False
|
||||||
|
|
||||||
# 自动选择网络
|
# 自动选择网络
|
||||||
@@ -660,6 +740,37 @@ class NetworkManager:
|
|||||||
return self._connect_tcp_via_4g()
|
return self._connect_tcp_via_4g()
|
||||||
return False
|
return False
|
||||||
|
|
||||||
|
def _check_4g_connection(self):
|
||||||
|
"""检查4G TCP连接是否仍然有效(通过查询PDP地址验证网络附着状态)"""
|
||||||
|
try:
|
||||||
|
atc = hardware_manager.at_client
|
||||||
|
if atc is None:
|
||||||
|
return False
|
||||||
|
if not self._uart4g_lock.acquire(timeout=3000):
|
||||||
|
# 获取锁超时说明有其他操作在进行,视为连接仍有效
|
||||||
|
return True
|
||||||
|
try:
|
||||||
|
r = atc.send("AT+CGPADDR=1", "OK", 3000)
|
||||||
|
m = re.search(r'\+CGPADDR:\s*1,"([^"]+)"', r)
|
||||||
|
ip = m.group(1) if m else ""
|
||||||
|
if ip and ip != "0.0.0.0":
|
||||||
|
return True
|
||||||
|
# 无IP或IP无效,尝试重新激活PDP
|
||||||
|
self.logger.warning("[4G-TCP] PDP地址无效,尝试重新激活")
|
||||||
|
atc.send("AT+MIPCALL=1,1", "OK", 15000)
|
||||||
|
r2 = atc.send("AT+CGPADDR=1", "OK", 3000)
|
||||||
|
m2 = re.search(r'\+CGPADDR:\s*1,"([^"]+)"', r2)
|
||||||
|
ip2 = m2.group(1) if m2 else ""
|
||||||
|
if ip2 and ip2 != "0.0.0.0":
|
||||||
|
return True
|
||||||
|
self.logger.error("[4G-TCP] 重新激活PDP仍无有效IP,连接已断开")
|
||||||
|
return False
|
||||||
|
finally:
|
||||||
|
self._uart4g_lock.release()
|
||||||
|
except Exception as e:
|
||||||
|
self.logger.warning(f"[4G-TCP] 连接检查异常: {e}")
|
||||||
|
return True # 异常时不误判断线
|
||||||
|
|
||||||
def _wrap_wifi_tls(self, plain_sock, hostname):
|
def _wrap_wifi_tls(self, plain_sock, hostname):
|
||||||
"""
|
"""
|
||||||
在已建立的 TCP socket 上做 TLS(WiFi 走主机 ssl 库;4G 仍用模组 AT+SSL)。
|
在已建立的 TCP socket 上做 TLS(WiFi 走主机 ssl 库;4G 仍用模组 AT+SSL)。
|
||||||
@@ -1007,6 +1118,8 @@ class NetworkManager:
|
|||||||
total += n
|
total += n
|
||||||
|
|
||||||
hardware_manager.uart4g.write(b"\x1A")
|
hardware_manager.uart4g.write(b"\x1A")
|
||||||
|
with hardware_manager.at_client._q_lock:
|
||||||
|
hardware_manager.at_client._rx = b""
|
||||||
r = hardware_manager.at_client.send("", "OK", 8000)
|
r = hardware_manager.at_client.send("", "OK", 8000)
|
||||||
if ("SEND OK" in r) or ("OK" in r) or ("+MIPSEND" in r):
|
if ("SEND OK" in r) or ("OK" in r) or ("+MIPSEND" in r):
|
||||||
return True
|
return True
|
||||||
@@ -1696,8 +1809,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
|
||||||
@@ -1723,7 +1834,7 @@ class NetworkManager:
|
|||||||
continue
|
continue
|
||||||
|
|
||||||
if not self.connect_server():
|
if not self.connect_server():
|
||||||
time.sleep_ms(5000)
|
time.sleep_ms(1000)
|
||||||
continue
|
continue
|
||||||
|
|
||||||
# 发送登录包
|
# 发送登录包
|
||||||
@@ -1744,7 +1855,7 @@ class NetworkManager:
|
|||||||
self.disconnect_server()
|
self.disconnect_server()
|
||||||
except:
|
except:
|
||||||
pass
|
pass
|
||||||
time.sleep_ms(2000)
|
time.sleep_ms(500)
|
||||||
continue
|
continue
|
||||||
|
|
||||||
self.logger.info("➡️ 登录包已发送,等待确认...")
|
self.logger.info("➡️ 登录包已发送,等待确认...")
|
||||||
@@ -1933,8 +2044,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 +2072,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)
|
||||||
@@ -2033,9 +2142,20 @@ class NetworkManager:
|
|||||||
battery_data = {
|
battery_data = {
|
||||||
"battery": battery_percent,
|
"battery": battery_percent,
|
||||||
"voltage": round(float(voltage), 3),
|
"voltage": round(float(voltage), 3),
|
||||||
|
"netType": self.network_type,
|
||||||
}
|
}
|
||||||
self.safe_enqueue(battery_data, 2)
|
self.safe_enqueue(battery_data, 2)
|
||||||
self.logger.info(f"电量上报: {battery_percent}%")
|
self.logger.info(f"电量上报: {battery_percent}% 充电: {is_charging()}")
|
||||||
|
if is_charging():
|
||||||
|
self.safe_enqueue(
|
||||||
|
{
|
||||||
|
"cmd": 700,
|
||||||
|
},
|
||||||
|
2,
|
||||||
|
)
|
||||||
|
elif inner_cmd == 700:
|
||||||
|
self.logger.warning("服务器下发关机!!!")
|
||||||
|
exit(-1)
|
||||||
elif inner_cmd == 5: # OTA 升级
|
elif inner_cmd == 5: # OTA 升级
|
||||||
inner_data = data_obj.get("data", {}) if isinstance(data_obj, dict) else {}
|
inner_data = data_obj.get("data", {}) if isinstance(data_obj, dict) else {}
|
||||||
ssid = inner_data.get("ssid")
|
ssid = inner_data.get("ssid")
|
||||||
@@ -2078,7 +2198,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,8 +2207,8 @@ 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:
|
||||||
ip = os.popen(
|
ip = os.popen(
|
||||||
@@ -2147,21 +2267,23 @@ class NetworkManager:
|
|||||||
2)
|
2)
|
||||||
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,12 +2312,14 @@ 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:
|
||||||
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:
|
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
|
self._tcp_connected = False
|
||||||
try:
|
try:
|
||||||
self.disconnect_server()
|
self.disconnect_server()
|
||||||
@@ -2230,8 +2354,8 @@ class NetworkManager:
|
|||||||
pass
|
pass
|
||||||
break
|
break
|
||||||
else:
|
else:
|
||||||
# 不立即断开,让下一轮心跳再试;同时缩短一点等待,提升恢复速度
|
# 不立即断开,让下一轮心跳再试
|
||||||
time.sleep_ms(200)
|
time.sleep_ms(50)
|
||||||
continue
|
continue
|
||||||
else:
|
else:
|
||||||
send_hartbeat_fail_count = 0
|
send_hartbeat_fail_count = 0
|
||||||
@@ -2261,8 +2385,8 @@ class NetworkManager:
|
|||||||
self._send_event.clear()
|
self._send_event.clear()
|
||||||
|
|
||||||
self._tcp_connected = False
|
self._tcp_connected = False
|
||||||
self.logger.error("连接异常,2秒后重连...")
|
self.logger.error("连接异常,50ms后重连...")
|
||||||
time.sleep_ms(2000)
|
time.sleep_ms(50)
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
# TCP主循环的顶层异常捕获,防止线程静默退出
|
# TCP主循环的顶层异常捕获,防止线程静默退出
|
||||||
@@ -2270,7 +2394,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秒后重试连接
|
||||||
|
|
||||||
|
|
||||||
# 创建全局单例实例
|
# 创建全局单例实例
|
||||||
|
|||||||
@@ -5,10 +5,11 @@
|
|||||||
提供电压、电流监测和充电状态检测
|
提供电压、电流监测和充电状态检测
|
||||||
"""
|
"""
|
||||||
import config
|
import config
|
||||||
|
import os
|
||||||
|
import subprocess
|
||||||
from logger_manager import logger_manager
|
from logger_manager import logger_manager
|
||||||
from maix import time as maix_time
|
from maix import time as maix_time
|
||||||
|
|
||||||
|
|
||||||
_INA226_PRESENT = None
|
_INA226_PRESENT = None
|
||||||
|
|
||||||
|
|
||||||
@@ -85,7 +86,7 @@ def get_bus_voltage():
|
|||||||
def get_current():
|
def get_current():
|
||||||
"""
|
"""
|
||||||
读取电流(单位:mA)
|
读取电流(单位:mA)
|
||||||
正数表示充电,负数表示放电
|
当前电源板实测:正数表示放电,负数表示充电。
|
||||||
|
|
||||||
INA226 电流计算公式:
|
INA226 电流计算公式:
|
||||||
Current = (Current Register Value) × Current_LSB
|
Current = (Current Register Value) × Current_LSB
|
||||||
@@ -96,13 +97,13 @@ def get_current():
|
|||||||
return 0.0
|
return 0.0
|
||||||
raw = read_register(config.REG_CURRENT)
|
raw = read_register(config.REG_CURRENT)
|
||||||
# INA226 电流寄存器是16位有符号整数
|
# INA226 电流寄存器是16位有符号整数
|
||||||
# 最高位是符号位:0=正(充电),1=负(放电)
|
# 最高位是符号位;电流方向含义取决于电源板的采样电阻接线方向。
|
||||||
# 计算 Current_LSB(根据 CALIBRATION_VALUE)
|
# 计算 Current_LSB(根据 CALIBRATION_VALUE)
|
||||||
current_lsb = 0.001 * config.CALIBRATION_VALUE / 4096 # 单位:A
|
current_lsb = 0.001 * config.CALIBRATION_VALUE / 4096 # 单位:A
|
||||||
# 处理有符号数:如果最高位为1,转换为负数
|
# 处理有符号数:如果最高位为1,转换为负数
|
||||||
if raw & 0x8000: # 最高位为1,表示负数(放电)
|
if raw & 0x8000:
|
||||||
signed_raw = raw - 0x10000 # 转换为有符号整数
|
signed_raw = raw - 0x10000 # 转换为有符号整数
|
||||||
else: # 最高位为0,表示正数(充电)
|
else:
|
||||||
signed_raw = raw
|
signed_raw = raw
|
||||||
# 转换为毫安
|
# 转换为毫安
|
||||||
current_ma = signed_raw * current_lsb * 1000
|
current_ma = signed_raw * current_lsb * 1000
|
||||||
@@ -129,7 +130,7 @@ def is_charging(threshold_ma=10.0):
|
|||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
current = get_current()
|
current = get_current()
|
||||||
is_charge = current > threshold_ma
|
is_charge = current < -abs(float(threshold_ma))
|
||||||
return is_charge
|
return is_charge
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger = logger_manager.logger
|
logger = logger_manager.logger
|
||||||
@@ -159,7 +160,7 @@ def voltage_to_percent(voltage):
|
|||||||
return 0
|
return 0
|
||||||
if v <= 0:
|
if v <= 0:
|
||||||
return 0
|
return 0
|
||||||
return int(int(_BATTERY_MONITOR.get_soc(v) * 10) / 10) # 截断而不是四舍五入
|
return int(int(_BATTERY_MONITOR.get_soc(v) * 10) / 10) # 截断而不是四舍五入
|
||||||
|
|
||||||
|
|
||||||
class BatteryMonitor:
|
class BatteryMonitor:
|
||||||
|
|||||||
+6
-2
@@ -320,9 +320,11 @@ def process_shot(adc_val):
|
|||||||
logger = logger_manager.logger
|
logger = logger_manager.logger
|
||||||
|
|
||||||
try:
|
try:
|
||||||
network_manager.safe_enqueue({"shoot_event": "start"}, msg_type=2, high=True)
|
|
||||||
frame = camera_manager.read_frame()
|
frame = camera_manager.read_frame()
|
||||||
|
|
||||||
|
# 网络事件移到拍照之后,避免阻塞拍照
|
||||||
|
network_manager.safe_enqueue({"shoot_event": "start"}, msg_type=2, high=True)
|
||||||
|
|
||||||
# 调用算法分析
|
# 调用算法分析
|
||||||
analysis_result = analyze_shot(frame)
|
analysis_result = analyze_shot(frame)
|
||||||
|
|
||||||
@@ -518,6 +520,7 @@ def process_shot(adc_val):
|
|||||||
laser_manager.flash_laser(config.FLASH_LASER_DURATION_MS)
|
laser_manager.flash_laser(config.FLASH_LASER_DURATION_MS)
|
||||||
|
|
||||||
# 保存图像(异步队列,与 main.py 一致)
|
# 保存图像(异步队列,与 main.py 一致)
|
||||||
|
_force_save = (dx is None and dy is None) and getattr(config, "SAVE_IMAGE_ON_FAILURE", False)
|
||||||
enqueue_save_shot(
|
enqueue_save_shot(
|
||||||
result_img,
|
result_img,
|
||||||
center,
|
center,
|
||||||
@@ -527,8 +530,9 @@ def process_shot(adc_val):
|
|||||||
(x, y),
|
(x, y),
|
||||||
distance_m,
|
distance_m,
|
||||||
shot_id=shot_id,
|
shot_id=shot_id,
|
||||||
photo_dir=config.PHOTO_DIR if config.SAVE_IMAGE_ENABLED else None,
|
photo_dir=config.PHOTO_DIR if (config.SAVE_IMAGE_ENABLED or _force_save) else None,
|
||||||
yolo_roi_xyxy=yolo_roi_xyxy if draw_yolo_roi else None,
|
yolo_roi_xyxy=yolo_roi_xyxy if draw_yolo_roi else None,
|
||||||
|
force_save=_force_save,
|
||||||
)
|
)
|
||||||
|
|
||||||
if logger:
|
if logger:
|
||||||
|
|||||||
@@ -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.4), "
|
||||||
|
f"距离OK={distance < max_distance}, 大小OK={size_ratio >= 0.4}")
|
||||||
|
|
||||||
|
# 允许红圈在黄圈外侧或内侧,只要大小相近(较小/较大 >= 0.5)
|
||||||
|
if distance < max_distance and size_ratio >= 0.4:
|
||||||
|
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/shot_1830921_0_no_target.jpg"
|
||||||
|
|
||||||
|
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.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 优化算法
|
||||||
|
# 2.15.15 优化wifi连接
|
||||||
|
# 2.15.16 修复wifi连接问题
|
||||||
|
# 2.15.17 修复wifi连接问题
|
||||||
|
# 2.15.18 wifi连接成功重新登录
|
||||||
+1
-1
@@ -4,6 +4,6 @@
|
|||||||
应用版本号
|
应用版本号
|
||||||
每次 OTA 更新时,只需要更新这个文件中的版本号
|
每次 OTA 更新时,只需要更新这个文件中的版本号
|
||||||
"""
|
"""
|
||||||
VERSION = '2.15.9'
|
VERSION = '2.16.4'
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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.4:
|
||||||
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,8 +644,17 @@ 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:
|
||||||
logger.debug("Debug -> 未找到匹配的红色圆圈,可能是误识别")
|
# 黄圈高置信度兜底:大且圆时跳过红圈验证
|
||||||
|
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()}")
|
logger.debug(f"[detect_circle_v3] step 4 fin {datetime.now()}")
|
||||||
|
|
||||||
@@ -782,12 +797,12 @@ def estimate_pixel(physical_distance_cm, target_distance_m):
|
|||||||
|
|
||||||
def _save_shot_image_impl(img_cv, center, radius, method, ellipse_params,
|
def _save_shot_image_impl(img_cv, center, radius, method, ellipse_params,
|
||||||
laser_point, distance_m, shot_id=None, photo_dir=None,
|
laser_point, distance_m, shot_id=None, photo_dir=None,
|
||||||
yolo_roi_xyxy=None):
|
yolo_roi_xyxy=None, force_save=False):
|
||||||
"""
|
"""
|
||||||
内部实现:在 img_cv (numpy HWC RGB) 上绘制标注并保存。
|
内部实现:在 img_cv (numpy HWC RGB) 上绘制标注并保存。
|
||||||
由 save_shot_image(同步)和存图 worker(异步)调用。
|
由 save_shot_image(同步)和存图 worker(异步)调用。
|
||||||
"""
|
"""
|
||||||
if not config.SAVE_IMAGE_ENABLED:
|
if not config.SAVE_IMAGE_ENABLED and not force_save:
|
||||||
return None
|
return None
|
||||||
if photo_dir is None:
|
if photo_dir is None:
|
||||||
photo_dir = config.PHOTO_DIR
|
photo_dir = config.PHOTO_DIR
|
||||||
@@ -923,11 +938,12 @@ def start_save_shot_worker():
|
|||||||
|
|
||||||
def enqueue_save_shot(result_img, center, radius, method, ellipse_params,
|
def enqueue_save_shot(result_img, center, radius, method, ellipse_params,
|
||||||
laser_point, distance_m, shot_id=None, photo_dir=None,
|
laser_point, distance_m, shot_id=None, photo_dir=None,
|
||||||
yolo_roi_xyxy=None):
|
yolo_roi_xyxy=None, force_save=False):
|
||||||
"""
|
"""
|
||||||
将存图任务放入队列,由 worker 异步保存。主线程传入 result_img 的复制,不阻塞。
|
将存图任务放入队列,由 worker 异步保存。主线程传入 result_img 的复制,不阻塞。
|
||||||
|
force_save=True 时,忽略 SAVE_IMAGE_ENABLED 配置强制保存(用于检测失败时的调试图像)。
|
||||||
"""
|
"""
|
||||||
if not config.SAVE_IMAGE_ENABLED:
|
if not config.SAVE_IMAGE_ENABLED and not force_save:
|
||||||
return
|
return
|
||||||
if photo_dir is None:
|
if photo_dir is None:
|
||||||
photo_dir = config.PHOTO_DIR
|
photo_dir = config.PHOTO_DIR
|
||||||
@@ -950,6 +966,7 @@ def enqueue_save_shot(result_img, center, radius, method, ellipse_params,
|
|||||||
shot_id,
|
shot_id,
|
||||||
photo_dir,
|
photo_dir,
|
||||||
yolo_roi_xyxy,
|
yolo_roi_xyxy,
|
||||||
|
force_save,
|
||||||
)
|
)
|
||||||
try:
|
try:
|
||||||
_save_queue.put_nowait(task)
|
_save_queue.put_nowait(task)
|
||||||
@@ -961,12 +978,12 @@ def enqueue_save_shot(result_img, center, radius, method, ellipse_params,
|
|||||||
|
|
||||||
def save_shot_image(result_img, center, radius, method, ellipse_params,
|
def save_shot_image(result_img, center, radius, method, ellipse_params,
|
||||||
laser_point, distance_m, shot_id=None, photo_dir=None,
|
laser_point, distance_m, shot_id=None, photo_dir=None,
|
||||||
yolo_roi_xyxy=None):
|
yolo_roi_xyxy=None, force_save=False):
|
||||||
"""
|
"""
|
||||||
保存射击图像(带标注)。同步调用,会阻塞。
|
保存射击图像(带标注)。同步调用,会阻塞。
|
||||||
主流程建议使用 enqueue_save_shot;此处保留供校准、测试等场景使用。
|
主流程建议使用 enqueue_save_shot;此处保留供校准、测试等场景使用。
|
||||||
"""
|
"""
|
||||||
if not config.SAVE_IMAGE_ENABLED:
|
if not config.SAVE_IMAGE_ENABLED and not force_save:
|
||||||
return None
|
return None
|
||||||
if photo_dir is None:
|
if photo_dir is None:
|
||||||
photo_dir = config.PHOTO_DIR
|
photo_dir = config.PHOTO_DIR
|
||||||
@@ -983,6 +1000,7 @@ def save_shot_image(result_img, center, radius, method, ellipse_params,
|
|||||||
shot_id,
|
shot_id,
|
||||||
photo_dir,
|
photo_dir,
|
||||||
yolo_roi_xyxy,
|
yolo_roi_xyxy,
|
||||||
|
force_save,
|
||||||
)
|
)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger = logger_manager.logger
|
logger = logger_manager.logger
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
@@ -238,7 +239,6 @@ class WiFiManager:
|
|||||||
old_conf = _read_text(conf_path)
|
old_conf = _read_text(conf_path)
|
||||||
old_boot_ssid = _read_text(ssid_file)
|
old_boot_ssid = _read_text(ssid_file)
|
||||||
old_boot_pass = _read_text(pass_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:
|
||||||
try:
|
try:
|
||||||
@@ -250,9 +250,13 @@ class WiFiManager:
|
|||||||
_write_text(conf_path, full_conf)
|
_write_text(conf_path, full_conf)
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
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(ssid_file, ssid.strip())
|
||||||
_write_text(pass_file, password.strip())
|
_write_text(pass_file, password.strip())
|
||||||
|
|
||||||
@@ -292,7 +296,6 @@ class WiFiManager:
|
|||||||
if not persist:
|
if not persist:
|
||||||
# 不持久化:把 /boot 恢复成旧值(不重启,当前连接保持不变)
|
# 不持久化:把 /boot 恢复成旧值(不重启,当前连接保持不变)
|
||||||
_restore_boot(old_boot_ssid, old_boot_pass)
|
_restore_boot(old_boot_ssid, old_boot_pass)
|
||||||
_restore_boot_wpa(old_boot_wpa)
|
|
||||||
self.logger.info("[WIFI] 网络验证通过,但按 persist=False 回滚 /boot 凭证(不重启)")
|
self.logger.info("[WIFI] 网络验证通过,但按 persist=False 回滚 /boot 凭证(不重启)")
|
||||||
else:
|
else:
|
||||||
self.logger.info("[WIFI] 网络验证通过,/boot 凭证已保留(持久化)")
|
self.logger.info("[WIFI] 网络验证通过,/boot 凭证已保留(持久化)")
|
||||||
@@ -306,7 +309,6 @@ class WiFiManager:
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
# 失败:回滚 /boot 和 /etc,重启 WiFi 恢复旧网络
|
# 失败:回滚 /boot 和 /etc,重启 WiFi 恢复旧网络
|
||||||
_restore_boot(old_boot_ssid, old_boot_pass)
|
_restore_boot(old_boot_ssid, old_boot_pass)
|
||||||
_restore_boot_wpa(old_boot_wpa)
|
|
||||||
try:
|
try:
|
||||||
if old_conf is not None:
|
if old_conf is not None:
|
||||||
_write_text(conf_path, old_conf)
|
_write_text(conf_path, old_conf)
|
||||||
@@ -351,7 +353,11 @@ class WiFiManager:
|
|||||||
else:
|
else:
|
||||||
full_conf = build_sta_conf_open(ssid)
|
full_conf = build_sta_conf_open(ssid)
|
||||||
_write_text(conf_path, full_conf)
|
_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:
|
except ValueError as e:
|
||||||
return False, str(e)
|
return False, str(e)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
@@ -542,34 +548,45 @@ 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:
|
||||||
self.logger.warning("[WiFi Monitor] 监测线程已在运行")
|
if self._wifi_quality_monitor_thread is not None and self._wifi_quality_monitor_thread.is_alive():
|
||||||
return
|
self.logger.warning("[WiFi Monitor] 监测线程已在运行")
|
||||||
|
return
|
||||||
|
|
||||||
self._network_type_callback = network_type_callback
|
self._network_type_callback = network_type_callback
|
||||||
self._on_poor_quality_callback = on_poor_quality_callback
|
self._on_poor_quality_callback = on_poor_quality_callback
|
||||||
self._wifi_quality_stop_event.clear()
|
self._wifi_quality_stop_event.clear()
|
||||||
self._wifi_quality_monitor_thread = threading.Thread(
|
self._wifi_quality_monitor_thread = threading.Thread(
|
||||||
target=self._quality_monitor_loop,
|
target=self._quality_monitor_loop,
|
||||||
daemon=True,
|
daemon=True,
|
||||||
name="wifi_quality_monitor"
|
name="wifi_quality_monitor"
|
||||||
)
|
)
|
||||||
self._wifi_quality_monitor_thread.start()
|
self._wifi_quality_monitor_thread.start()
|
||||||
self.logger.info("[WiFi Monitor] 已启动后台监测线程")
|
self.logger.info("[WiFi Monitor] 已启动后台监测线程")
|
||||||
|
|
||||||
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:
|
||||||
return
|
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()
|
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:
|
|
||||||
self._wifi_quality_monitor_thread = None
|
with self._wifi_quality_lock:
|
||||||
self.logger.info("[WiFi Monitor] 已停止后台监测线程")
|
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):
|
def _quality_monitor_loop(self):
|
||||||
"""
|
"""
|
||||||
|
|||||||
Reference in New Issue
Block a user