Compare commits
36
Commits
860f9c84c3
..
2.17.0
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e67c410325 | ||
|
|
0c8ab1508f | ||
|
|
d30c432143 | ||
|
|
c5338ccac7 | ||
|
|
231937afba | ||
|
|
70aa072164 | ||
|
|
42026d43e5 | ||
|
|
8a83deddd3 | ||
|
|
6a1d3fe2bd | ||
|
|
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 |
Vendored
+3
@@ -0,0 +1,3 @@
|
|||||||
|
{
|
||||||
|
"cmake.sourceDirectory": "E:/code/code/code/new/new/new/new/new/nw/2.17.0/archery/cpp_ext"
|
||||||
|
}
|
||||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -1,6 +1,6 @@
|
|||||||
id: t11
|
id: t11
|
||||||
name: t11
|
name: t11
|
||||||
version: 2.15.9
|
version: 2.17.15
|
||||||
author: t11
|
author: t11
|
||||||
icon: ''
|
icon: ''
|
||||||
desc: t11
|
desc: t11
|
||||||
@@ -18,8 +18,8 @@ files:
|
|||||||
- laser_manager.py
|
- laser_manager.py
|
||||||
- logger_manager.py
|
- logger_manager.py
|
||||||
- main.py
|
- main.py
|
||||||
- model_270139.cvimodel
|
- model_317828.cvimodel
|
||||||
- model_270139.mud
|
- model_317828.mud
|
||||||
- network.py
|
- network.py
|
||||||
- ota_curl.sh
|
- ota_curl.sh
|
||||||
- ota_manager.py
|
- ota_manager.py
|
||||||
|
|||||||
+3
-2
@@ -76,6 +76,7 @@ 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._waiting = True
|
||||||
self._expect = expect_b
|
self._expect = expect_b
|
||||||
@@ -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:]
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
+26
-1
@@ -8,6 +8,15 @@ import threading
|
|||||||
import config
|
import config
|
||||||
from logger_manager import logger_manager
|
from logger_manager import logger_manager
|
||||||
|
|
||||||
|
_USE_CV = False
|
||||||
|
try:
|
||||||
|
import cv2
|
||||||
|
import numpy as np
|
||||||
|
from maix import image as _maix_image
|
||||||
|
_USE_CV = True
|
||||||
|
except ImportError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
class CameraManager:
|
class CameraManager:
|
||||||
"""相机管理器(单例)"""
|
"""相机管理器(单例)"""
|
||||||
@@ -101,7 +110,23 @@ class CameraManager:
|
|||||||
with self._camera_lock:
|
with self._camera_lock:
|
||||||
if self._camera is None:
|
if self._camera is None:
|
||||||
self.init_camera()
|
self.init_camera()
|
||||||
return self._camera.read()
|
frame = self._camera.read()
|
||||||
|
if frame is not None and _USE_CV:
|
||||||
|
try:
|
||||||
|
v_flip = getattr(config, 'CAMERA_V_FLIP', False)
|
||||||
|
h_mirror = getattr(config, 'CAMERA_H_MIRROR', False)
|
||||||
|
if v_flip or h_mirror:
|
||||||
|
img_cv = _maix_image.image2cv(frame, False, False)
|
||||||
|
if v_flip and h_mirror:
|
||||||
|
img_cv = cv2.flip(img_cv, -1)
|
||||||
|
elif v_flip:
|
||||||
|
img_cv = cv2.flip(img_cv, 0)
|
||||||
|
elif h_mirror:
|
||||||
|
img_cv = cv2.flip(img_cv, 1)
|
||||||
|
frame = _maix_image.cv2image(img_cv, False, False)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
return frame
|
||||||
|
|
||||||
def show(self, image):
|
def show(self, image):
|
||||||
"""
|
"""
|
||||||
|
|||||||
@@ -15,6 +15,8 @@ LOCAL_FILENAME = APP_DIR + "/main_tmp.py"
|
|||||||
# 相机初始化分辨率(CameraManager / main.py 使用)
|
# 相机初始化分辨率(CameraManager / main.py 使用)
|
||||||
CAMERA_WIDTH = 640
|
CAMERA_WIDTH = 640
|
||||||
CAMERA_HEIGHT = 480
|
CAMERA_HEIGHT = 480
|
||||||
|
CAMERA_V_FLIP = True # 摄像头垂直翻转(上下颠倒时设为 True)
|
||||||
|
CAMERA_H_MIRROR = True # 摄像头水平镜像(左右反了时设为 True)
|
||||||
|
|
||||||
# 三角形检测缩图比例:默认按相机最长边缩到 1/2(性能更稳;可按需调整)
|
# 三角形检测缩图比例:默认按相机最长边缩到 1/2(性能更稳;可按需调整)
|
||||||
# 取值范围建议 (0.25 ~ 1.0];1.0 表示不缩图
|
# 取值范围建议 (0.25 ~ 1.0];1.0 表示不缩图
|
||||||
@@ -24,7 +26,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 建连耗时采样次数,取中位数
|
||||||
@@ -234,10 +236,10 @@ TRIANGLE_BLACKHAT_KERNEL_FRAC = 0.018 # 核大小 ≈ min(h,w)*frac,取奇数
|
|||||||
# ── YOLO(NPU) 靶环 ROI → 裁剪后再跑三角形(减小 CPU 处理面积)──────────────────
|
# ── YOLO(NPU) 靶环 ROI → 裁剪后再跑三角形(减小 CPU 处理面积)──────────────────
|
||||||
# 日志里 net_in=W×H 来自 .mud 模型(det.input_width/height),不是这里配置的。
|
# 日志里 net_in=W×H 来自 .mud 模型(det.input_width/height),不是这里配置的。
|
||||||
TRIANGLE_YOLO_ROI_ENABLE = True
|
TRIANGLE_YOLO_ROI_ENABLE = True
|
||||||
TRIANGLE_YOLO_MODEL_PATH = APP_DIR + "/model_270139.mud"
|
TRIANGLE_YOLO_MODEL_PATH = APP_DIR + "/model_317211.mud"
|
||||||
# 参与 ROI 的类别:多类时只填「整靶/靶环」的 id;不要填角标类,否则 union 仍可对,但 largest 会偏小。
|
# 参与 ROI 的类别:多类时只填「整靶/靶环」的 id;不要填角标类,否则 union 仍可对,但 largest 会偏小。
|
||||||
TRIANGLE_YOLO_RING_CLASS_IDS = (0,)
|
TRIANGLE_YOLO_RING_CLASS_IDS = (0,)
|
||||||
TRIANGLE_YOLO_CONF_TH = 0.7
|
TRIANGLE_YOLO_CONF_TH = 0.9
|
||||||
TRIANGLE_YOLO_IOU_TH = 0.45
|
TRIANGLE_YOLO_IOU_TH = 0.45
|
||||||
# YOLO 首次/临界帧可能在高阈值下 0 框;启用后仅在 0 候选时用较低阈值重试一次。
|
# YOLO 首次/临界帧可能在高阈值下 0 框;启用后仅在 0 候选时用较低阈值重试一次。
|
||||||
# 后续仍会经过 min_box_side、ROI aspect、三角形几何校验,避免直接放大假阳性。
|
# 后续仍会经过 min_box_side、ROI aspect、三角形几何校验,避免直接放大假阳性。
|
||||||
@@ -262,6 +264,16 @@ TRIANGLE_SAMPLE_PATCH_HALF_PX = 2
|
|||||||
# 开机阶段预加载 YOLO detector;detect 使用 dual_buff=False,避免返回上一帧结果。
|
# 开机阶段预加载 YOLO detector;detect 使用 dual_buff=False,避免返回上一帧结果。
|
||||||
TRIANGLE_YOLO_PRELOAD_ON_BOOT = False
|
TRIANGLE_YOLO_PRELOAD_ON_BOOT = False
|
||||||
|
|
||||||
|
# YOLO target size classification: class 0=20cm, class 1=40cm.
|
||||||
|
TARGET_CLASS_YOLO_ENABLE = True
|
||||||
|
TARGET_CLASS_YOLO_MODEL_PATH = APP_DIR + "/model_317828.mud"
|
||||||
|
TARGET_CLASS_YOLO_LABELS = (20, 40)
|
||||||
|
TARGET_CLASS_YOLO_CONF_TH = 0.66
|
||||||
|
TARGET_CLASS_YOLO_IOU_TH = 0.45
|
||||||
|
TARGET_CLASS_YOLO_RETRY_ON_EMPTY = False
|
||||||
|
TARGET_CLASS_YOLO_RETRY_CONF_TH = 0.25
|
||||||
|
TARGET_CLASS_YOLO_PRELOAD_ON_BOOT = True
|
||||||
|
|
||||||
# ── 第二段 YOLO:仅在 Stage1 裁切出的靶环图上推理(与合成 stage2 训练数据一致)→ 子框内传统算法取直角点 ──
|
# ── 第二段 YOLO:仅在 Stage1 裁切出的靶环图上推理(与合成 stage2 训练数据一致)→ 子框内传统算法取直角点 ──
|
||||||
# Stage1 靶环裁切内如何找黑三角标记(对比耗时时可切换):
|
# Stage1 靶环裁切内如何找黑三角标记(对比耗时时可切换):
|
||||||
# "yolo" — 调 Stage2 黑三角模型得子框,再子框内传统提取(需 TRIANGLE_BLACK_YOLO_ENABLE=True)。
|
# "yolo" — 调 Stage2 黑三角模型得子框,再子框内传统提取(需 TRIANGLE_BLACK_YOLO_ENABLE=True)。
|
||||||
@@ -308,14 +320,25 @@ 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 = True # 是否保存图像(True=保存,False=不保存)
|
||||||
|
SAVE_IMAGE_ON_FAILURE = False # 检测失败时是否强制保存图像(供调试测试用)
|
||||||
PHOTO_DIR = "/root/phot" # 照片存储目录
|
PHOTO_DIR = "/root/phot" # 照片存储目录
|
||||||
MAX_IMAGES = 1000
|
MAX_IMAGES = 1000
|
||||||
|
SAVE_RAW_IMAGE_ENABLED = True # 额外保存完整原始帧(不画框、不画点、不裁剪)
|
||||||
|
RAW_IMAGE_DIR = PHOTO_DIR + "/raw"
|
||||||
|
RAW_IMAGE_MAX_IMAGES = MAX_IMAGES
|
||||||
# Stage2 调试目录(默认 PHOTO_DIR/stage2_roi)内 JPEG 最多保留张数;None 表示与 MAX_IMAGES 相同
|
# Stage2 调试目录(默认 PHOTO_DIR/stage2_roi)内 JPEG 最多保留张数;None 表示与 MAX_IMAGES 相同
|
||||||
TRIANGLE_BLACK_YOLO_STAGE2_ROI_MAX_IMAGES = None
|
TRIANGLE_BLACK_YOLO_STAGE2_ROI_MAX_IMAGES = None
|
||||||
|
|
||||||
SHOW_CAMERA_PHOTO_WHILE_SHOOTING = False # 是否在拍摄时显示摄像头图像(True=显示,False=不显示),建议在连着USB测试过程中打开
|
SHOW_CAMERA_PHOTO_WHILE_SHOOTING = True # 是否在拍摄时显示摄像头图像(True=显示,False=不显示),建议在连着USB测试过程中打开
|
||||||
|
|
||||||
# ==================== OTA配置 ====================
|
# ==================== OTA配置 ====================
|
||||||
MAX_BACKUPS = 5
|
MAX_BACKUPS = 5
|
||||||
|
|||||||
+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
|
||||||
|
|
||||||
# 补充:因为初始化的时候,激光会亮,先关了它
|
# 补充:因为初始化的时候,激光会亮,先关了它
|
||||||
@@ -132,6 +132,7 @@ def cmd_str():
|
|||||||
sync_system_time_from_4g()
|
sync_system_time_from_4g()
|
||||||
|
|
||||||
# 2.1 WiFi 热点配网兜底:仅当 STA 与 4G 均不可用时起 AP + HTTP;提交后删 /boot/wifi.ap、建 wifi.sta 并 reboot
|
# 2.1 WiFi 热点配网兜底:仅当 STA 与 4G 均不可用时起 AP + HTTP;提交后删 /boot/wifi.ap、建 wifi.sta 并 reboot
|
||||||
|
_ota_pending_path = f"{config.APP_DIR}/ota_pending.json"
|
||||||
try:
|
try:
|
||||||
from wifi_config_httpd import maybe_start_wifi_ap_fallback
|
from wifi_config_httpd import maybe_start_wifi_ap_fallback
|
||||||
|
|
||||||
@@ -162,15 +163,21 @@ def cmd_str():
|
|||||||
and _loc_black == "yolo"
|
and _loc_black == "yolo"
|
||||||
and bool(getattr(config, "TRIANGLE_BLACK_YOLO_PRELOAD_ON_BOOT", True))
|
and bool(getattr(config, "TRIANGLE_BLACK_YOLO_PRELOAD_ON_BOOT", True))
|
||||||
)
|
)
|
||||||
_preload_yolo = _preload_yolo or _need_black_preload
|
_need_target_preload = (
|
||||||
if _preload_yolo:
|
bool(getattr(config, "TARGET_CLASS_YOLO_ENABLE", False))
|
||||||
|
and bool(getattr(config, "TARGET_CLASS_YOLO_PRELOAD_ON_BOOT", True))
|
||||||
|
)
|
||||||
|
_preload_yolo = _preload_yolo or _need_black_preload or _need_target_preload
|
||||||
|
if _preload_yolo and not os.path.exists(_ota_pending_path):
|
||||||
preload_yolo_detector(logger)
|
preload_yolo_detector(logger)
|
||||||
|
elif _preload_yolo and logger:
|
||||||
|
logger.warning("[YOLO] ota_pending.json found; skip model preload until rollback check")
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
if logger:
|
if logger:
|
||||||
logger.warning(f"[YOLO-ROI] 启动预加载异常(不影响后续射箭): {e}")
|
logger.warning(f"[YOLO-ROI] 启动预加载异常(不影响后续射箭): {e}")
|
||||||
|
|
||||||
# 3. 启动时检查:是否需要恢复备份
|
# 3. 启动时检查:是否需要恢复备份
|
||||||
pending_path = f"{config.APP_DIR}/ota_pending.json"
|
pending_path = _ota_pending_path
|
||||||
if os.path.exists(pending_path):
|
if os.path.exists(pending_path):
|
||||||
try:
|
try:
|
||||||
with open(pending_path, 'r', encoding='utf-8') as f:
|
with open(pending_path, 'r', encoding='utf-8') as f:
|
||||||
@@ -245,8 +252,12 @@ 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)
|
||||||
|
or getattr(config, "SAVE_RAW_IMAGE_ENABLED", 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 +289,46 @@ def cmd_str():
|
|||||||
logger.info("系统准备完成...")
|
logger.info("系统准备完成...")
|
||||||
|
|
||||||
last_adc_trigger = 0
|
last_adc_trigger = 0
|
||||||
|
trigger_adc_val = 0 # 触发时的气压值,气压需降回此值以下才能再次触发
|
||||||
|
# 读取一次ADC初始值,防止开机时传感器已有压力导致误触发
|
||||||
|
enable_check = True
|
||||||
|
try:
|
||||||
|
last_adc_val = hardware_manager.adc_obj.read()
|
||||||
|
except Exception:
|
||||||
|
last_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
|
||||||
|
if config.AIR_PRESSURE_lOG:
|
||||||
t1_ms = time.ticks_ms()
|
t1_ms = time.ticks_ms()
|
||||||
n = len(pressure_buf)
|
n = len(pressure_buf)
|
||||||
avg = (pressure_sum / n) if n else 0
|
avg = (pressure_sum / n) if n else 0
|
||||||
avg_abs = (pressure_abs_sum / n) if n else 0
|
|
||||||
# 一行输出:方便后处理画曲线;同时带上统计信息便于快速看波峰
|
|
||||||
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} avg_abs={avg_abs:.3f} "
|
f"min={pressure_min} max={pressure_max} avg={avg:.1f} "
|
||||||
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:
|
if logger:
|
||||||
logger.debug(line)
|
logger.debug(line)
|
||||||
else:
|
else:
|
||||||
print(line)
|
print(line)
|
||||||
|
# 无论是否记录日志,都必须清空 buffer,否则内存泄漏
|
||||||
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 +363,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,25 +377,24 @@ 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:
|
# 突变增量检测:压力增量大于300时触发
|
||||||
# print(f"adc :{adc_val}")
|
# 触发后需等气压降到触发值以下才重新检测增量
|
||||||
if adc_val >= config.ADC_TRIGGER_THRESHOLD:
|
if adc_val < trigger_adc_val :
|
||||||
|
enable_check = True
|
||||||
|
if (adc_val - last_adc_val) > 500 and enable_check:
|
||||||
hardware_manager.start_idle_timer() # 重新计时
|
hardware_manager.start_idle_timer() # 重新计时
|
||||||
diff_ms = current_time - last_adc_trigger
|
|
||||||
if diff_ms < 3000:
|
|
||||||
logger.info(f"[MAIN] 扳机触发过于频繁, {diff_ms}ms")
|
|
||||||
continue
|
|
||||||
last_adc_trigger = current_time
|
last_adc_trigger = current_time
|
||||||
# 触发前先把缓存刷出来,避免波形被长耗时处理截断
|
trigger_adc_val = adc_val # 记录触发时的气压值
|
||||||
|
last_adc_val = adc_val # 更新基准值,防止连续增量误触发
|
||||||
|
enable_check = False
|
||||||
_flush_pressure_buf("before_trigger")
|
_flush_pressure_buf("before_trigger")
|
||||||
|
|
||||||
try:
|
try:
|
||||||
@@ -404,10 +412,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:
|
time.sleep_ms(1)
|
||||||
logger.error(f"[MAIN] 显示异常: {e}")
|
last_adc_val = adc_val
|
||||||
time.sleep_ms(5)
|
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
# 主循环的顶层异常捕获,防止程序静默退出
|
# 主循环的顶层异常捕获,防止程序静默退出
|
||||||
|
|||||||
Binary file not shown.
@@ -1,7 +1,7 @@
|
|||||||
|
|
||||||
[basic]
|
[basic]
|
||||||
type = cvimodel
|
type = cvimodel
|
||||||
model = model_270139.cvimodel
|
model = model_317189.cvimodel
|
||||||
|
|
||||||
[extra]
|
[extra]
|
||||||
model_type = yolov5
|
model_type = yolov5
|
||||||
@@ -9,5 +9,5 @@ input_type = rgb
|
|||||||
mean = 0, 0, 0
|
mean = 0, 0, 0
|
||||||
scale = 0.00392156862745098, 0.00392156862745098, 0.00392156862745098
|
scale = 0.00392156862745098, 0.00392156862745098, 0.00392156862745098
|
||||||
anchors = 10, 13, 16, 30, 33, 23, 30, 61, 62, 45, 59, 119, 116, 90, 156, 198, 373, 326
|
anchors = 10, 13, 16, 30, 33, 23, 30, 61, 62, 45, 59, 119, 116, 90, 156, 198, 373, 326
|
||||||
labels = 黑三角和圆环
|
labels = circle, triangle
|
||||||
|
|
||||||
Binary file not shown.
@@ -0,0 +1,13 @@
|
|||||||
|
|
||||||
|
[basic]
|
||||||
|
type = cvimodel
|
||||||
|
model = model_317211.cvimodel
|
||||||
|
|
||||||
|
[extra]
|
||||||
|
model_type = yolov5
|
||||||
|
input_type = rgb
|
||||||
|
mean = 0, 0, 0
|
||||||
|
scale = 0.00392156862745098, 0.00392156862745098, 0.00392156862745098
|
||||||
|
anchors = 10, 13, 16, 30, 33, 23, 30, 61, 62, 45, 59, 119, 116, 90, 156, 198, 373, 326
|
||||||
|
labels = circle, triangle
|
||||||
|
|
||||||
Binary file not shown.
@@ -0,0 +1,13 @@
|
|||||||
|
|
||||||
|
[basic]
|
||||||
|
type = cvimodel
|
||||||
|
model = model_317423.cvimodel
|
||||||
|
|
||||||
|
[extra]
|
||||||
|
model_type = yolov5
|
||||||
|
input_type = rgb
|
||||||
|
mean = 0, 0, 0
|
||||||
|
scale = 0.00392156862745098, 0.00392156862745098, 0.00392156862745098
|
||||||
|
anchors = 10, 13, 16, 30, 33, 23, 30, 61, 62, 45, 59, 119, 116, 90, 156, 198, 373, 326
|
||||||
|
labels = 20, 10, 40
|
||||||
|
|
||||||
Binary file not shown.
@@ -0,0 +1,13 @@
|
|||||||
|
|
||||||
|
[basic]
|
||||||
|
type = cvimodel
|
||||||
|
model = model_317704.cvimodel
|
||||||
|
|
||||||
|
[extra]
|
||||||
|
model_type = yolov5
|
||||||
|
input_type = rgb
|
||||||
|
mean = 0, 0, 0
|
||||||
|
scale = 0.00392156862745098, 0.00392156862745098, 0.00392156862745098
|
||||||
|
anchors = 10, 13, 16, 30, 33, 23, 30, 61, 62, 45, 59, 119, 116, 90, 156, 198, 373, 326
|
||||||
|
labels = 40, circle, triangle
|
||||||
|
|
||||||
Binary file not shown.
@@ -1,7 +1,7 @@
|
|||||||
|
|
||||||
[basic]
|
[basic]
|
||||||
type = cvimodel
|
type = cvimodel
|
||||||
model = model_270820.cvimodel
|
model = model_317828.cvimodel
|
||||||
|
|
||||||
[extra]
|
[extra]
|
||||||
model_type = yolov5
|
model_type = yolov5
|
||||||
@@ -9,5 +9,5 @@ input_type = rgb
|
|||||||
mean = 0, 0, 0
|
mean = 0, 0, 0
|
||||||
scale = 0.00392156862745098, 0.00392156862745098, 0.00392156862745098
|
scale = 0.00392156862745098, 0.00392156862745098, 0.00392156862745098
|
||||||
anchors = 10, 13, 16, 30, 33, 23, 30, 61, 62, 45, 59, 119, 116, 90, 156, 198, 373, 326
|
anchors = 10, 13, 16, 30, 33, 23, 30, 61, 62, 45, 59, 119, 116, 90, 156, 198, 373, 326
|
||||||
labels = triangle
|
labels = 20, 40
|
||||||
|
|
||||||
+150
-26
@@ -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,7 +2207,7 @@ class NetworkManager:
|
|||||||
self.logger.info(f"ssid: {ssid}")
|
self.logger.info(f"ssid: {ssid}")
|
||||||
self.logger.info(f"password: {password}")
|
self.logger.info(f"password: {password}")
|
||||||
ota_manager._start_update_thread()
|
ota_manager._start_update_thread()
|
||||||
_thread.start_new_thread(ota_manager.handle_wifi_and_update,
|
self._spawn_cmd_thread(ota_manager.handle_wifi_and_update,
|
||||||
(ssid, password, ota_url))
|
(ssid, password, ota_url))
|
||||||
elif inner_cmd == 6:
|
elif inner_cmd == 6:
|
||||||
try:
|
try:
|
||||||
@@ -2148,20 +2268,22 @@ class NetworkManager:
|
|||||||
else:
|
else:
|
||||||
self.logger.info(f"[LOG_UPLOAD] 收到日志上传命令,目标URL: {upload_url}")
|
self.logger.info(f"[LOG_UPLOAD] 收到日志上传命令,目标URL: {upload_url}")
|
||||||
# 在新线程中执行上传,避免阻塞主循环
|
# 在新线程中执行上传,避免阻塞主循环
|
||||||
import _thread
|
self._spawn_cmd_thread(
|
||||||
_thread.start_new_thread(
|
|
||||||
self._upload_log_file,
|
self._upload_log_file,
|
||||||
(upload_url, wifi_ssid, wifi_password, include_rotated, max_files,
|
(upload_url, wifi_ssid, wifi_password, include_rotated, max_files,
|
||||||
archive_format)
|
archive_format)
|
||||||
)
|
)
|
||||||
elif inner_cmd == 200:
|
elif inner_cmd == 200:
|
||||||
self.logger.info("[LASER] cmd200 在后台线程执行检测")
|
self.logger.info("[LASER] cmd200 在后台线程执行检测")
|
||||||
import _thread
|
self._spawn_cmd_thread(self._cmd200_detect_laser, ())
|
||||||
_thread.start_new_thread(self._cmd200_detect_laser, ())
|
|
||||||
elif inner_cmd == 300:
|
elif inner_cmd == 300:
|
||||||
self.logger.info("[New Ota] cmd300 在后台线程执行OTA")
|
self.logger.info("[New Ota] cmd300 在后台线程执行OTA")
|
||||||
import _thread
|
self._spawn_cmd_thread(self._cmd300_ota, (data_obj,))
|
||||||
_thread.start_new_thread(self._cmd300_ota, (data_obj,))
|
elif inner_cmd == 600:
|
||||||
|
self.logger.info("[conn wifi] cmd600 在后台线程执行连接wifi: {data_obj}")
|
||||||
|
self._spawn_cmd_thread(self._cmd600_conn_wifi, (data_obj,))
|
||||||
|
elif inner_cmd == 601:
|
||||||
|
pass
|
||||||
else: # data的结构不是 dict
|
else: # data的结构不是 dict
|
||||||
self.logger.info(f"[NET] body={body}, {time.time()}")
|
self.logger.info(f"[NET] body={body}, {time.time()}")
|
||||||
else:
|
else:
|
||||||
@@ -2190,11 +2312,13 @@ class NetworkManager:
|
|||||||
msg_type, data_dict = item
|
msg_type, data_dict = item
|
||||||
pkt = self._netcore.make_packet(msg_type, data_dict)
|
pkt = self._netcore.make_packet(msg_type, data_dict)
|
||||||
if not self.tcp_send_raw(pkt):
|
if not self.tcp_send_raw(pkt):
|
||||||
# 发送失败:将消息放回队首,触发重连(避免丢消息)
|
# 发送失败:将消息放回队首(队列满则丢弃)
|
||||||
with self.get_queue_lock():
|
with self.get_queue_lock():
|
||||||
if item_is_high:
|
if item_is_high:
|
||||||
|
if len(self._high_send_queue) < config.MAX_SEND_QUEUE_SIZE:
|
||||||
self._high_send_queue.insert(0, item)
|
self._high_send_queue.insert(0, item)
|
||||||
else:
|
else:
|
||||||
|
if len(self._normal_send_queue) < config.MAX_SEND_QUEUE_SIZE:
|
||||||
self._normal_send_queue.insert(0, item)
|
self._normal_send_queue.insert(0, item)
|
||||||
self._tcp_connected = False
|
self._tcp_connected = False
|
||||||
try:
|
try:
|
||||||
@@ -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
|
||||||
|
|||||||
+55
-7
@@ -8,7 +8,12 @@ from laser_manager import laser_manager
|
|||||||
from logger_manager import logger_manager
|
from logger_manager import logger_manager
|
||||||
from network import network_manager
|
from network import network_manager
|
||||||
from triangle_target import load_camera_from_xml, load_triangle_positions, try_triangle_scoring
|
from triangle_target import load_camera_from_xml, load_triangle_positions, try_triangle_scoring
|
||||||
from vision import estimate_distance, detect_circle_v3, enqueue_save_shot
|
from vision import (
|
||||||
|
estimate_distance,
|
||||||
|
detect_circle_v3,
|
||||||
|
enqueue_save_shot,
|
||||||
|
enqueue_save_raw_shot,
|
||||||
|
)
|
||||||
from maix import image, time
|
from maix import image, time
|
||||||
|
|
||||||
# 缓存相机标定与三角形位置,避免每次射箭重复读磁盘
|
# 缓存相机标定与三角形位置,避免每次射箭重复读磁盘
|
||||||
@@ -320,9 +325,28 @@ 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()
|
||||||
|
|
||||||
|
# Copy the untouched frame before any detection or drawing.
|
||||||
|
from shot_id_generator import shot_id_generator
|
||||||
|
shot_id = shot_id_generator.generate_id()
|
||||||
|
enqueue_save_raw_shot(frame, shot_id)
|
||||||
|
|
||||||
|
# 网络事件移到拍照之后,避免阻塞拍照
|
||||||
|
network_manager.safe_enqueue({"shoot_event": "start"}, msg_type=2, high=True)
|
||||||
|
|
||||||
|
# Classify only the current shot frame; never reuse a previous result.
|
||||||
|
target_class_result = None
|
||||||
|
try:
|
||||||
|
from target_roi_yolo import try_get_target_class_from_yolo
|
||||||
|
|
||||||
|
target_class_result = try_get_target_class_from_yolo(frame, logger=logger)
|
||||||
|
if logger:
|
||||||
|
logger.info(f"[YOLO-TARGET] 当前箭业务结果: {target_class_result}")
|
||||||
|
except Exception as exc:
|
||||||
|
if logger:
|
||||||
|
logger.warning(f"[YOLO-TARGET] 当前箭分类失败,按未知处理: {exc}")
|
||||||
|
|
||||||
# 调用算法分析
|
# 调用算法分析
|
||||||
analysis_result = analyze_shot(frame)
|
analysis_result = analyze_shot(frame)
|
||||||
|
|
||||||
@@ -366,10 +390,6 @@ def process_shot(adc_val):
|
|||||||
if dx is None and dy is None and logger:
|
if dx is None and dy is None and logger:
|
||||||
logger.warning("[MAIN] 未检测到偏移量(三角形与圆形均失败),但会保存图像")
|
logger.warning("[MAIN] 未检测到偏移量(三角形与圆形均失败),但会保存图像")
|
||||||
|
|
||||||
# 生成射箭ID
|
|
||||||
from shot_id_generator import shot_id_generator
|
|
||||||
shot_id = shot_id_generator.generate_id()
|
|
||||||
|
|
||||||
if logger:
|
if logger:
|
||||||
logger.info(f"[MAIN] 射箭ID: {shot_id}")
|
logger.info(f"[MAIN] 射箭ID: {shot_id}")
|
||||||
|
|
||||||
@@ -382,11 +402,25 @@ def process_shot(adc_val):
|
|||||||
srv_y = round(float(dy), 4) if dy is not None else 200.0
|
srv_y = round(float(dy), 4) if dy is not None else 200.0
|
||||||
|
|
||||||
# 构造上报数据
|
# 构造上报数据
|
||||||
|
target_label = (
|
||||||
|
target_class_result.get("label")
|
||||||
|
if isinstance(target_class_result, dict)
|
||||||
|
else None
|
||||||
|
)
|
||||||
|
target_confidence = (
|
||||||
|
target_class_result.get("confidence")
|
||||||
|
if isinstance(target_class_result, dict)
|
||||||
|
else None
|
||||||
|
)
|
||||||
inner_data = {
|
inner_data = {
|
||||||
"shot_id": shot_id,
|
"shot_id": shot_id,
|
||||||
"x": srv_x,
|
"x": srv_x,
|
||||||
"y": srv_y,
|
"y": srv_y,
|
||||||
"r": 20.0, # 保留字段(服务端当前忽略,物理外环半径 cm)
|
"r": 20.0, # 保留字段(服务端当前忽略,物理外环半径 cm)
|
||||||
|
"target_class": target_label,
|
||||||
|
"target_class_confidence": (
|
||||||
|
float(target_confidence) if target_confidence is not None else None
|
||||||
|
),
|
||||||
"d": round((distance_m or 0.0) * 100),
|
"d": round((distance_m or 0.0) * 100),
|
||||||
"d_laser": round((laser_distance_m or 0.0) * 100),
|
"d_laser": round((laser_distance_m or 0.0) * 100),
|
||||||
"d_laser_quality": laser_signal_quality,
|
"d_laser_quality": laser_signal_quality,
|
||||||
@@ -413,7 +447,19 @@ def process_shot(adc_val):
|
|||||||
inner_data["ellipse_center_x"] = None
|
inner_data["ellipse_center_x"] = None
|
||||||
inner_data["ellipse_center_y"] = None
|
inner_data["ellipse_center_y"] = None
|
||||||
|
|
||||||
|
# 记录这组 inner_data 即将进入上报队列的本地时间,精确到毫秒。
|
||||||
|
upload_time_ms = int(time_std.time() * 1000)
|
||||||
|
upload_time_sec, upload_time_millis = divmod(upload_time_ms, 1000)
|
||||||
|
inner_data["upload_time"] = (
|
||||||
|
time_std.strftime("%Y-%m-%d %H:%M:%S", time_std.localtime(upload_time_sec))
|
||||||
|
+ f".{upload_time_millis:03d}"
|
||||||
|
)
|
||||||
report_data = {"cmd": 1, "data": inner_data}
|
report_data = {"cmd": 1, "data": inner_data}
|
||||||
|
if logger:
|
||||||
|
logger.info(
|
||||||
|
f"[REPORT-TARGET] enqueue shot_id={shot_id}, "
|
||||||
|
f"target_class={target_label}, confidence={target_confidence}"
|
||||||
|
)
|
||||||
network_manager.safe_enqueue(report_data, msg_type=2, high=True)
|
network_manager.safe_enqueue(report_data, msg_type=2, high=True)
|
||||||
|
|
||||||
# 数据上报后再画标注,不干扰检测阶段的原始画面
|
# 数据上报后再画标注,不干扰检测阶段的原始画面
|
||||||
@@ -518,6 +564,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 +574,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:
|
||||||
|
|||||||
+155
-4
@@ -89,6 +89,29 @@ def _stage2_roi_crop_save_worker(
|
|||||||
_detector_by_path = {}
|
_detector_by_path = {}
|
||||||
|
|
||||||
|
|
||||||
|
def _resolve_model_path(model_path: str):
|
||||||
|
"""Resolve a model in either the installed app or MaixVision run directory."""
|
||||||
|
model_path = (model_path or "").strip()
|
||||||
|
if model_path and os.path.isfile(model_path):
|
||||||
|
return model_path
|
||||||
|
if not model_path:
|
||||||
|
return ""
|
||||||
|
name = os.path.basename(model_path)
|
||||||
|
module_dir = os.path.dirname(os.path.abspath(__file__))
|
||||||
|
candidates = (
|
||||||
|
os.path.join(module_dir, name),
|
||||||
|
os.path.join(module_dir, "test", name),
|
||||||
|
os.path.join("/tmp/maixpy_run", name),
|
||||||
|
os.path.join("/tmp/maixpy_run", "test", name),
|
||||||
|
os.path.join(os.getcwd(), name),
|
||||||
|
os.path.join(os.getcwd(), "test", name),
|
||||||
|
)
|
||||||
|
for candidate in candidates:
|
||||||
|
if os.path.isfile(candidate):
|
||||||
|
return candidate
|
||||||
|
return model_path
|
||||||
|
|
||||||
|
|
||||||
def reset_yolo_detector_cache():
|
def reset_yolo_detector_cache():
|
||||||
"""切换模型路径时可调用(通常不必)。"""
|
"""切换模型路径时可调用(通常不必)。"""
|
||||||
global _detector_by_path
|
global _detector_by_path
|
||||||
@@ -103,10 +126,19 @@ def _get_detector(model_path: str):
|
|||||||
return _detector_by_path[model_path]
|
return _detector_by_path[model_path]
|
||||||
try:
|
try:
|
||||||
from maix import nn
|
from maix import nn
|
||||||
except ImportError:
|
except Exception:
|
||||||
return None
|
return None
|
||||||
_detector_by_path[model_path] = nn.YOLOv5(model=model_path, dual_buff=False)
|
# YOLO is an optional capability. A broken/incompatible model must not
|
||||||
return _detector_by_path[model_path]
|
# abort boot (especially before the OTA rollback check).
|
||||||
|
try:
|
||||||
|
detector = nn.YOLOv5(model=model_path, dual_buff=False)
|
||||||
|
except Exception:
|
||||||
|
# Cache the failure to avoid retrying a broken native load every frame.
|
||||||
|
# reset_yolo_detector_cache() clears this after a model replacement.
|
||||||
|
_detector_by_path[model_path] = None
|
||||||
|
return None
|
||||||
|
_detector_by_path[model_path] = detector
|
||||||
|
return detector
|
||||||
|
|
||||||
|
|
||||||
def preload_yolo_detector(logger=None):
|
def preload_yolo_detector(logger=None):
|
||||||
@@ -175,6 +207,23 @@ def preload_yolo_detector(logger=None):
|
|||||||
% (_loc_black,)
|
% (_loc_black,)
|
||||||
)
|
)
|
||||||
|
|
||||||
|
if bool(getattr(cfg, "TARGET_CLASS_YOLO_ENABLE", False)) and bool(
|
||||||
|
getattr(cfg, "TARGET_CLASS_YOLO_PRELOAD_ON_BOOT", True)
|
||||||
|
):
|
||||||
|
class_model_path = _resolve_model_path(
|
||||||
|
getattr(cfg, "TARGET_CLASS_YOLO_MODEL_PATH", "") or ""
|
||||||
|
)
|
||||||
|
class_detector = _get_detector(class_model_path)
|
||||||
|
if class_detector is None:
|
||||||
|
if logger:
|
||||||
|
logger.warning(
|
||||||
|
f"[YOLO-TARGET] 预加载失败:无法加载模型 {class_model_path}"
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
ok = True
|
||||||
|
if logger:
|
||||||
|
logger.info(f"[YOLO-TARGET] 靶规格模型已预加载: {class_model_path}")
|
||||||
|
|
||||||
return ok
|
return ok
|
||||||
|
|
||||||
|
|
||||||
@@ -206,8 +255,10 @@ def _det_obj_class_id(o):
|
|||||||
if v is None:
|
if v is None:
|
||||||
continue
|
continue
|
||||||
try:
|
try:
|
||||||
|
if callable(v):
|
||||||
|
v = v()
|
||||||
return int(float(v))
|
return int(float(v))
|
||||||
except (TypeError, ValueError):
|
except (TypeError, ValueError, AttributeError):
|
||||||
continue
|
continue
|
||||||
return None
|
return None
|
||||||
|
|
||||||
@@ -242,6 +293,106 @@ def _normalize_objs(objs):
|
|||||||
return out
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def _det_obj_score(o):
|
||||||
|
"""Return confidence across supported Maix YOLO result formats."""
|
||||||
|
for key in ("score", "confidence", "conf", "prob"):
|
||||||
|
if hasattr(o, key):
|
||||||
|
try:
|
||||||
|
value = getattr(o, key)
|
||||||
|
if callable(value):
|
||||||
|
value = value()
|
||||||
|
value = float(value)
|
||||||
|
if value == value:
|
||||||
|
return value
|
||||||
|
except (TypeError, ValueError, AttributeError):
|
||||||
|
pass
|
||||||
|
return 0.0
|
||||||
|
|
||||||
|
|
||||||
|
def try_get_target_class_from_yolo(maix_frame, logger=None):
|
||||||
|
"""Classify the current target as 20cm or 40cm; return None if unknown."""
|
||||||
|
try:
|
||||||
|
import config as cfg
|
||||||
|
except Exception:
|
||||||
|
return None
|
||||||
|
|
||||||
|
if not bool(getattr(cfg, "TARGET_CLASS_YOLO_ENABLE", False)):
|
||||||
|
return None
|
||||||
|
model_path = _resolve_model_path(
|
||||||
|
getattr(cfg, "TARGET_CLASS_YOLO_MODEL_PATH", "") or ""
|
||||||
|
)
|
||||||
|
if not os.path.isfile(model_path):
|
||||||
|
if logger:
|
||||||
|
logger.warning(f"[YOLO-TARGET] 模型文件不存在: {model_path}")
|
||||||
|
return None
|
||||||
|
detector = _get_detector(model_path)
|
||||||
|
if detector is None:
|
||||||
|
if logger:
|
||||||
|
logger.warning("[YOLO-TARGET] 无法加载 nn.YOLOv5")
|
||||||
|
return None
|
||||||
|
|
||||||
|
conf_th = float(getattr(cfg, "TARGET_CLASS_YOLO_CONF_TH", 0.5))
|
||||||
|
iou_th = float(getattr(cfg, "TARGET_CLASS_YOLO_IOU_TH", 0.45))
|
||||||
|
labels = getattr(cfg, "TARGET_CLASS_YOLO_LABELS", (20, 40))
|
||||||
|
if isinstance(labels, str):
|
||||||
|
labels = tuple(x.strip() for x in labels.split(",") if x.strip())
|
||||||
|
labels = tuple(labels)
|
||||||
|
|
||||||
|
def _detect(threshold):
|
||||||
|
try:
|
||||||
|
raw = detector.detect(maix_frame, conf_th=threshold, iou_th=iou_th)
|
||||||
|
except Exception as exc:
|
||||||
|
if logger:
|
||||||
|
logger.warning(f"[YOLO-TARGET] detect 异常: {exc}")
|
||||||
|
return []
|
||||||
|
return _normalize_objs(raw if raw is not None else [])
|
||||||
|
|
||||||
|
def _candidates(objs):
|
||||||
|
found = []
|
||||||
|
for obj in objs:
|
||||||
|
class_id = _det_obj_class_id(obj)
|
||||||
|
if class_id is None or class_id < 0 or class_id >= len(labels):
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
label = int(float(labels[class_id]))
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
continue
|
||||||
|
if label in (20, 40):
|
||||||
|
found.append((label, class_id, _det_obj_score(obj)))
|
||||||
|
return found
|
||||||
|
|
||||||
|
objects = _detect(conf_th)
|
||||||
|
candidates = _candidates(objects)
|
||||||
|
if logger and objects:
|
||||||
|
logger.info(
|
||||||
|
"[YOLO-TARGET] 原始框=%d, 解析类别=%s"
|
||||||
|
% (
|
||||||
|
len(objects),
|
||||||
|
[(_det_obj_class_id(o), _det_obj_score(o)) for o in objects[:8]],
|
||||||
|
)
|
||||||
|
)
|
||||||
|
if not candidates and bool(
|
||||||
|
getattr(cfg, "TARGET_CLASS_YOLO_RETRY_ON_EMPTY", False)
|
||||||
|
):
|
||||||
|
retry_th = float(getattr(cfg, "TARGET_CLASS_YOLO_RETRY_CONF_TH", conf_th))
|
||||||
|
if 0 < retry_th < conf_th:
|
||||||
|
candidates = _candidates(_detect(retry_th))
|
||||||
|
|
||||||
|
if not candidates:
|
||||||
|
if logger:
|
||||||
|
logger.warning("[YOLO-TARGET] 当前帧未识别到 20/40,按未知处理")
|
||||||
|
return None
|
||||||
|
|
||||||
|
label, class_id, confidence = max(candidates, key=lambda item: item[2])
|
||||||
|
result = {"label": label, "class_id": class_id, "confidence": confidence}
|
||||||
|
if logger:
|
||||||
|
logger.info(
|
||||||
|
f"[YOLO-TARGET] 当前帧分类={label}, class_id={class_id}, "
|
||||||
|
f"conf={confidence:.3f}"
|
||||||
|
)
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
def _det_to_src_xyxy(o, coord_mode: str, src_w: int, src_h: int, net_w: int, net_h: int):
|
def _det_to_src_xyxy(o, coord_mode: str, src_w: int, src_h: int, net_w: int, net_h: int):
|
||||||
"""把单个检测框转为全图坐标系下的 xyxy(半开区间语义与后续 clip 一致)。"""
|
"""把单个检测框转为全图坐标系下的 xyxy(半开区间语义与后续 clip 一致)。"""
|
||||||
x, y, w, h = float(o.x), float(o.y), float(o.w), float(o.h)
|
x, y, w, h = float(o.x), float(o.y), float(o.w), float(o.h)
|
||||||
|
|||||||
Binary file not shown.
Binary file not shown.
@@ -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)
|
||||||
@@ -0,0 +1,108 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
"""Run from MaixVision on PC to inspect the box's live 20/40 YOLO output."""
|
||||||
|
|
||||||
|
import os
|
||||||
|
|
||||||
|
from maix import app, camera, display, image, nn, time
|
||||||
|
|
||||||
|
|
||||||
|
# This file is sent to /tmp/maixpy_run by MaixVision. Keep the model path
|
||||||
|
# absolute so the script uses the model already installed on the box.
|
||||||
|
MODEL_PATH = "/maixapp/apps/t11/model_317181.mud"
|
||||||
|
CAMERA_WIDTH = 640
|
||||||
|
CAMERA_HEIGHT = 480
|
||||||
|
CONF_TH = 0.65
|
||||||
|
IOU_TH = 0.45
|
||||||
|
|
||||||
|
|
||||||
|
def _flatten_objects(raw):
|
||||||
|
if raw is None:
|
||||||
|
return []
|
||||||
|
if isinstance(raw, (list, tuple)):
|
||||||
|
result = []
|
||||||
|
for item in raw:
|
||||||
|
if isinstance(item, (list, tuple)):
|
||||||
|
result.extend(_flatten_objects(item))
|
||||||
|
else:
|
||||||
|
result.append(item)
|
||||||
|
return result
|
||||||
|
return [raw]
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
if not os.path.isfile(MODEL_PATH):
|
||||||
|
raise FileNotFoundError("model not found on box: " + MODEL_PATH)
|
||||||
|
|
||||||
|
detector = nn.YOLOv5(model=MODEL_PATH, dual_buff=False)
|
||||||
|
cam = camera.Camera(CAMERA_WIDTH, CAMERA_HEIGHT)
|
||||||
|
disp = display.Display()
|
||||||
|
|
||||||
|
labels = tuple(str(label) for label in detector.labels)
|
||||||
|
print("[YOLO] model:", MODEL_PATH)
|
||||||
|
print("[YOLO] labels:", labels)
|
||||||
|
print("[YOLO] conf=%.2f iou=%.2f" % (CONF_TH, IOU_TH))
|
||||||
|
|
||||||
|
fps = 0.0
|
||||||
|
frame_count = 0
|
||||||
|
last_log_ms = time.ticks_ms()
|
||||||
|
|
||||||
|
while not app.need_exit():
|
||||||
|
loop_start_ms = time.ticks_ms()
|
||||||
|
img = cam.read()
|
||||||
|
|
||||||
|
detect_start_ms = time.ticks_ms()
|
||||||
|
raw = detector.detect(img, conf_th=CONF_TH, iou_th=IOU_TH)
|
||||||
|
detect_ms = max(0, time.ticks_diff(time.ticks_ms(), detect_start_ms))
|
||||||
|
objects = _flatten_objects(raw)
|
||||||
|
|
||||||
|
candidates = []
|
||||||
|
for obj in objects:
|
||||||
|
class_id = int(obj.class_id)
|
||||||
|
score = float(obj.score)
|
||||||
|
label = labels[class_id] if 0 <= class_id < len(labels) else "unknown"
|
||||||
|
color = image.COLOR_GREEN if label in ("20", "40") else image.COLOR_RED
|
||||||
|
|
||||||
|
img.draw_rect(obj.x, obj.y, obj.w, obj.h, color=color)
|
||||||
|
img.draw_string(
|
||||||
|
obj.x,
|
||||||
|
max(0, obj.y - 16),
|
||||||
|
"%scm %.2f" % (label, score),
|
||||||
|
color=color,
|
||||||
|
)
|
||||||
|
if label in ("20", "40"):
|
||||||
|
candidates.append((score, label))
|
||||||
|
|
||||||
|
loop_ms = max(1, time.ticks_diff(time.ticks_ms(), loop_start_ms))
|
||||||
|
instant_fps = 1000.0 / float(loop_ms)
|
||||||
|
fps = instant_fps if frame_count == 0 else fps * 0.9 + instant_fps * 0.1
|
||||||
|
|
||||||
|
if candidates:
|
||||||
|
best_score, best_label = max(candidates, key=lambda item: item[0])
|
||||||
|
status = "TARGET %scm %.2f" % (best_label, best_score)
|
||||||
|
status_color = image.COLOR_GREEN
|
||||||
|
else:
|
||||||
|
status = "TARGET UNKNOWN"
|
||||||
|
status_color = image.COLOR_RED
|
||||||
|
|
||||||
|
img.draw_string(5, 5, status, color=status_color)
|
||||||
|
img.draw_string(
|
||||||
|
5,
|
||||||
|
25,
|
||||||
|
"infer=%dms fps=%.1f boxes=%d" % (detect_ms, fps, len(objects)),
|
||||||
|
color=image.COLOR_YELLOW,
|
||||||
|
)
|
||||||
|
disp.show(img)
|
||||||
|
|
||||||
|
frame_count += 1
|
||||||
|
now_ms = time.ticks_ms()
|
||||||
|
if time.ticks_diff(now_ms, last_log_ms) >= 1000:
|
||||||
|
print(
|
||||||
|
"[YOLO] %s infer=%dms fps=%.1f boxes=%d"
|
||||||
|
% (status, detect_ms, fps, len(objects))
|
||||||
|
)
|
||||||
|
last_log_ms = now_ms
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
@@ -0,0 +1,184 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
"""Offline baseline for traditional target-paper detection.
|
||||||
|
|
||||||
|
Dataset format: sibling .txt files use YOLO boxes and classes.txt maps ids
|
||||||
|
(the supplied dataset uses 0=40, 1=20, 2=10). This intentionally simple
|
||||||
|
baseline uses grayscale segmentation and contour geometry; it is useful as a
|
||||||
|
reference before adding more specialized black-triangle grouping.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import csv
|
||||||
|
import glob
|
||||||
|
import itertools
|
||||||
|
import os
|
||||||
|
|
||||||
|
import cv2
|
||||||
|
import numpy as np
|
||||||
|
|
||||||
|
|
||||||
|
def detect_white_papers(image: np.ndarray) -> list[tuple[int, int, int, int]]:
|
||||||
|
gray = cv2.cvtColor(image, cv2.COLOR_BGR2GRAY)
|
||||||
|
h, w = gray.shape[:2]
|
||||||
|
mask = cv2.inRange(gray, 120, 255)
|
||||||
|
mask = cv2.morphologyEx(mask, cv2.MORPH_CLOSE, np.ones((9, 9), np.uint8))
|
||||||
|
mask = cv2.morphologyEx(mask, cv2.MORPH_OPEN, np.ones((5, 5), np.uint8))
|
||||||
|
contours, _ = cv2.findContours(mask, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE)
|
||||||
|
out = []
|
||||||
|
for contour in contours:
|
||||||
|
x, y, bw, bh = cv2.boundingRect(contour)
|
||||||
|
area = float(bw * bh)
|
||||||
|
if area < 0.05 * w * h or min(bw, bh) < 80:
|
||||||
|
continue
|
||||||
|
fill = cv2.contourArea(contour) / max(area, 1.0)
|
||||||
|
aspect = bw / max(float(bh), 1.0)
|
||||||
|
if fill >= 0.45 and 0.4 <= aspect <= 2.5:
|
||||||
|
out.append((x, y, x + bw, y + bh))
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def detect_black_triangle_papers(image: np.ndarray):
|
||||||
|
"""Infer paper boxes from the four small black corner marks."""
|
||||||
|
gray = cv2.cvtColor(image, cv2.COLOR_BGR2GRAY)
|
||||||
|
mask = cv2.inRange(gray, 0, 100)
|
||||||
|
mask = cv2.morphologyEx(mask, cv2.MORPH_OPEN, np.ones((2, 2), np.uint8))
|
||||||
|
contours, _ = cv2.findContours(mask, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE)
|
||||||
|
points = []
|
||||||
|
for contour in contours:
|
||||||
|
x, y, bw, bh = cv2.boundingRect(contour)
|
||||||
|
area = cv2.contourArea(contour)
|
||||||
|
vertices = cv2.approxPolyDP(contour, 0.08 * cv2.arcLength(contour, True), True)
|
||||||
|
if 60 <= area <= 400 and 8 <= bw <= 24 and 8 <= bh <= 24:
|
||||||
|
if 3 <= len(vertices) <= 5 and 0.5 <= bw / max(bh, 1) <= 2.0:
|
||||||
|
points.append((x + bw / 2.0, y + bh / 2.0))
|
||||||
|
candidates = []
|
||||||
|
for group in itertools.combinations(points, 4):
|
||||||
|
xs = sorted(p[0] for p in group)
|
||||||
|
ys = sorted(p[1] for p in group)
|
||||||
|
span_x, span_y = xs[-1] - xs[0], ys[-1] - ys[0]
|
||||||
|
if span_x < 50 or span_y < 50 or not 0.45 < span_x / span_y < 1.5:
|
||||||
|
continue
|
||||||
|
corners = ((xs[0], ys[0]), (xs[-1], ys[0]),
|
||||||
|
(xs[0], ys[-1]), (xs[-1], ys[-1]))
|
||||||
|
error = max(min(np.hypot(p[0] - c[0], p[1] - c[1]) for c in corners)
|
||||||
|
for p in group) / max(span_x, span_y)
|
||||||
|
if error > 0.22:
|
||||||
|
continue
|
||||||
|
ex, ey = 0.12 * span_x, 0.12 * span_y
|
||||||
|
candidates.append((xs[0] - ex, ys[0] - ey,
|
||||||
|
xs[-1] + ex, ys[-1] + ey, error))
|
||||||
|
# A colored target ring supplies an independent center check. Hough is
|
||||||
|
# deliberately low-cost here because it runs only on the already small
|
||||||
|
# candidate list's source frame.
|
||||||
|
hsv = cv2.cvtColor(image, cv2.COLOR_BGR2HSV)
|
||||||
|
color = cv2.inRange(hsv, (0, 70, 45), (179, 255, 255))
|
||||||
|
color = cv2.morphologyEx(color, cv2.MORPH_OPEN, np.ones((5, 5), np.uint8))
|
||||||
|
ring_centers = []
|
||||||
|
for contour in cv2.findContours(color, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE)[0]:
|
||||||
|
area = cv2.contourArea(contour)
|
||||||
|
if area < 150:
|
||||||
|
continue
|
||||||
|
moments = cv2.moments(contour)
|
||||||
|
if moments["m00"]:
|
||||||
|
ring_centers.append((moments["m10"] / moments["m00"], moments["m01"] / moments["m00"]))
|
||||||
|
checked = []
|
||||||
|
for box in candidates:
|
||||||
|
if not ring_centers:
|
||||||
|
checked.append(box)
|
||||||
|
continue
|
||||||
|
x0, y0, x1, y1, err = box
|
||||||
|
inside = any(x0 - .15 * (x1 - x0) <= cx <= x1 + .15 * (x1 - x0)
|
||||||
|
and y0 - .15 * (y1 - y0) <= cy <= y1 + .15 * (y1 - y0)
|
||||||
|
for cx, cy in ring_centers)
|
||||||
|
if inside:
|
||||||
|
checked.append(box)
|
||||||
|
return sorted(checked, key=lambda x: x[-1])
|
||||||
|
|
||||||
|
|
||||||
|
def iou(a, b):
|
||||||
|
x0, y0 = max(a[0], b[0]), max(a[1], b[1])
|
||||||
|
x1, y1 = min(a[2], b[2]), min(a[3], b[3])
|
||||||
|
inter = max(0, x1 - x0) * max(0, y1 - y0)
|
||||||
|
aa = max(0, a[2] - a[0]) * max(0, a[3] - a[1])
|
||||||
|
bb = max(0, b[2] - b[0]) * max(0, b[3] - b[1])
|
||||||
|
return inter / max(aa + bb - inter, 1)
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
ap = argparse.ArgumentParser()
|
||||||
|
ap.add_argument("dataset", help="directory containing jpg and YOLO txt files")
|
||||||
|
ap.add_argument("--iou", type=float, default=0.5)
|
||||||
|
ap.add_argument("--out", default="traditional_eval_results.csv",
|
||||||
|
help="CSV output path; relative paths are next to the dataset")
|
||||||
|
ap.add_argument("--vis-dir", default="traditional_eval_images",
|
||||||
|
help="directory for annotated result images; empty disables")
|
||||||
|
args = ap.parse_args()
|
||||||
|
stats = {0: [0, 0], 1: [0, 0]}
|
||||||
|
rows = []
|
||||||
|
# OpenCV on some Windows builds cannot decode non-ASCII filenames. Work
|
||||||
|
# relative to the dataset directory so the supplied Chinese path is safe.
|
||||||
|
dataset = os.path.abspath(args.dataset)
|
||||||
|
os.chdir(dataset)
|
||||||
|
# cwd is now the dataset, so a relative output avoids Windows console
|
||||||
|
# encoding issues with the Chinese parent path.
|
||||||
|
vis_dir = args.vis_dir if args.vis_dir else ""
|
||||||
|
if vis_dir:
|
||||||
|
os.makedirs(vis_dir, exist_ok=True)
|
||||||
|
files = glob.glob(os.path.join("**", "*.jpg"), recursive=True)
|
||||||
|
for image_path in files:
|
||||||
|
label_path = os.path.splitext(image_path)[0] + ".txt"
|
||||||
|
if not os.path.isfile(label_path):
|
||||||
|
continue
|
||||||
|
image = cv2.imread(image_path)
|
||||||
|
if image is None:
|
||||||
|
continue
|
||||||
|
h, w = image.shape[:2]
|
||||||
|
predictions = detect_black_triangle_papers(image)
|
||||||
|
vis = image.copy()
|
||||||
|
for p in predictions:
|
||||||
|
cv2.rectangle(vis, (int(p[0]), int(p[1])), (int(p[2]), int(p[3])), (0, 255, 255), 2)
|
||||||
|
for line in open(label_path, encoding="utf-8", errors="ignore"):
|
||||||
|
z = line.split()
|
||||||
|
if len(z) < 5 or int(float(z[0])) not in stats:
|
||||||
|
continue
|
||||||
|
cls, cx, cy, bw, bh = int(float(z[0])), *map(float, z[1:5])
|
||||||
|
truth = (int((cx - bw / 2) * w), int((cy - bh / 2) * h),
|
||||||
|
int((cx + bw / 2) * w), int((cy + bh / 2) * h))
|
||||||
|
best = max((iou(truth, p) for p in predictions), default=0.0)
|
||||||
|
best_box = max(predictions, key=lambda p: iou(truth, p), default=())
|
||||||
|
stats[cls][0] += 1
|
||||||
|
stats[cls][1] += int(best >= args.iou)
|
||||||
|
rows.append({
|
||||||
|
"image": image_path,
|
||||||
|
"class_id": cls,
|
||||||
|
"truth_xyxy": ",".join(map(str, truth[:4])),
|
||||||
|
"pred_xyxy": ",".join(map(str, best_box[:4])) if best_box else "",
|
||||||
|
"iou": f"{best:.4f}",
|
||||||
|
"pass": int(best >= args.iou),
|
||||||
|
})
|
||||||
|
color = (0, 255, 0) if best >= args.iou else (0, 0, 255)
|
||||||
|
cv2.rectangle(vis, truth[:2], truth[2:4], color, 2)
|
||||||
|
cv2.putText(vis, f"GT {cls} IoU {best:.2f}",
|
||||||
|
(truth[0], max(16, truth[1] - 4)),
|
||||||
|
cv2.FONT_HERSHEY_SIMPLEX, 0.45, color, 1, cv2.LINE_AA)
|
||||||
|
if vis_dir:
|
||||||
|
name = os.path.splitext(os.path.basename(image_path))[0] + "_result.jpg"
|
||||||
|
cv2.imwrite(os.path.join(vis_dir, name), vis)
|
||||||
|
total = sum(v[0] for v in stats.values())
|
||||||
|
good = sum(v[1] for v in stats.values())
|
||||||
|
print(f"paper objects: {good}/{total} = {good / max(total, 1):.2%} (IoU >= {args.iou})")
|
||||||
|
for cls, (n, ok) in stats.items():
|
||||||
|
print(f"class {cls}: {ok}/{n} = {ok / max(n, 1):.2%}")
|
||||||
|
out_path = args.out if os.path.isabs(args.out) else os.path.join(dataset, args.out)
|
||||||
|
with open(out_path, "w", newline="", encoding="utf-8-sig") as fp:
|
||||||
|
writer = csv.DictWriter(fp, fieldnames=("image", "class_id", "truth_xyxy",
|
||||||
|
"pred_xyxy", "iou", "pass"))
|
||||||
|
writer.writeheader()
|
||||||
|
writer.writerows(rows)
|
||||||
|
print(f"details csv: {out_path}")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
+18
@@ -20,3 +20,21 @@
|
|||||||
# 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连接成功重新登录
|
||||||
|
# 2.16.4 优化射箭延迟
|
||||||
|
# 2.17.0 yolo标靶类别识别
|
||||||
|
# 2.17.1 26-08-19 17:39 压力传感修改 增量方式
|
||||||
|
|
||||||
|
# 2.17.2 26-08-24 17:56 靶纸识别模型更替
|
||||||
|
|
||||||
|
# 2.17.3 26-08-25 9:57 原图拍摄开关
|
||||||
|
|
||||||
|
# 2.17.4 26-08-25 14:57 模型修改
|
||||||
+1
-1
@@ -4,6 +4,6 @@
|
|||||||
应用版本号
|
应用版本号
|
||||||
每次 OTA 更新时,只需要更新这个文件中的版本号
|
每次 OTA 更新时,只需要更新这个文件中的版本号
|
||||||
"""
|
"""
|
||||||
VERSION = '2.15.9'
|
VERSION = '2.17.15'
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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,7 +644,16 @@ def detect_circle_v3(frame, laser_point=None, img_cv=None):
|
|||||||
})
|
})
|
||||||
matched = True
|
matched = True
|
||||||
break
|
break
|
||||||
if not matched and logger:
|
if not matched:
|
||||||
|
# 黄圈高置信度兜底:大且圆时跳过红圈验证
|
||||||
|
if area > 30 and circularity > 0.8:
|
||||||
|
valid_targets.append({
|
||||||
|
"center": yellow_center,
|
||||||
|
"radius": yellow_radius,
|
||||||
|
"ellipse": yellow_ellipse,
|
||||||
|
"area": area,
|
||||||
|
})
|
||||||
|
elif logger:
|
||||||
logger.debug("Debug -> 未找到匹配的红色圆圈,可能是误识别")
|
logger.debug("Debug -> 未找到匹配的红色圆圈,可能是误识别")
|
||||||
|
|
||||||
logger.debug(f"[detect_circle_v3] step 4 fin {datetime.now()}")
|
logger.debug(f"[detect_circle_v3] step 4 fin {datetime.now()}")
|
||||||
@@ -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
|
||||||
@@ -893,6 +908,11 @@ def _save_worker_loop():
|
|||||||
item = _save_queue.get()
|
item = _save_queue.get()
|
||||||
if item is None:
|
if item is None:
|
||||||
break
|
break
|
||||||
|
if isinstance(item, dict) and item.get("kind") == "raw":
|
||||||
|
_save_raw_image_impl(
|
||||||
|
item["img_cv"], item["shot_id"], item["photo_dir"]
|
||||||
|
)
|
||||||
|
else:
|
||||||
_save_shot_image_impl(*item)
|
_save_shot_image_impl(*item)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger = logger_manager.logger
|
logger = logger_manager.logger
|
||||||
@@ -921,13 +941,64 @@ def start_save_shot_worker():
|
|||||||
logger.info("[VISION] 存图 worker 线程已启动")
|
logger.info("[VISION] 存图 worker 线程已启动")
|
||||||
|
|
||||||
|
|
||||||
|
def _save_raw_image_impl(img_cv, shot_id, photo_dir):
|
||||||
|
"""保存相机完整原始帧,不添加任何检测标注。"""
|
||||||
|
logger = logger_manager.logger
|
||||||
|
try:
|
||||||
|
os.makedirs(photo_dir, exist_ok=True)
|
||||||
|
filename = os.path.join(photo_dir, f"shot_{shot_id}_raw.jpg")
|
||||||
|
image.cv2image(img_cv, False, False).save(filename)
|
||||||
|
prune_old_images_in_dir(
|
||||||
|
photo_dir,
|
||||||
|
getattr(config, "RAW_IMAGE_MAX_IMAGES", config.MAX_IMAGES),
|
||||||
|
logger,
|
||||||
|
"[VISION-RAW]",
|
||||||
|
)
|
||||||
|
if logger:
|
||||||
|
logger.info(f"[VISION-RAW] 已保存纯原图: {filename}")
|
||||||
|
return filename
|
||||||
|
except Exception as e:
|
||||||
|
if logger:
|
||||||
|
logger.error(f"[VISION-RAW] 保存纯原图失败: {e}")
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def enqueue_save_raw_shot(frame, shot_id, photo_dir=None):
|
||||||
|
"""立即复制相机帧并异步保存,避免后续识别和绘图修改原图。"""
|
||||||
|
if not getattr(config, "SAVE_RAW_IMAGE_ENABLED", False):
|
||||||
|
return
|
||||||
|
if photo_dir is None:
|
||||||
|
photo_dir = getattr(
|
||||||
|
config, "RAW_IMAGE_DIR", os.path.join(config.PHOTO_DIR, "raw")
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
img_copy = np.copy(image.image2cv(frame, False, False))
|
||||||
|
_save_queue.put_nowait(
|
||||||
|
{
|
||||||
|
"kind": "raw",
|
||||||
|
"img_cv": img_copy,
|
||||||
|
"shot_id": shot_id,
|
||||||
|
"photo_dir": photo_dir,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
except queue.Full:
|
||||||
|
logger = logger_manager.logger
|
||||||
|
if logger:
|
||||||
|
logger.warning("[VISION-RAW] 存图队列已满,跳过本次纯原图保存")
|
||||||
|
except Exception as e:
|
||||||
|
logger = logger_manager.logger
|
||||||
|
if logger:
|
||||||
|
logger.error(f"[VISION-RAW] 复制纯原图失败: {e}")
|
||||||
|
|
||||||
|
|
||||||
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 +1021,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 +1033,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 +1055,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,7 +548,8 @@ class WiFiManager:
|
|||||||
network_type_callback: 获取当前网络类型的回调函数
|
network_type_callback: 获取当前网络类型的回调函数
|
||||||
on_poor_quality_callback: WiFi质量差时的回调函数
|
on_poor_quality_callback: WiFi质量差时的回调函数
|
||||||
"""
|
"""
|
||||||
if self._wifi_quality_monitor_thread is not None:
|
with self._wifi_quality_lock:
|
||||||
|
if self._wifi_quality_monitor_thread is not None and self._wifi_quality_monitor_thread.is_alive():
|
||||||
self.logger.warning("[WiFi Monitor] 监测线程已在运行")
|
self.logger.warning("[WiFi Monitor] 监测线程已在运行")
|
||||||
return
|
return
|
||||||
|
|
||||||
@@ -559,15 +566,25 @@ class WiFiManager:
|
|||||||
|
|
||||||
def stop_quality_monitor(self):
|
def stop_quality_monitor(self):
|
||||||
"""停止 WiFi 质量监测线程"""
|
"""停止 WiFi 质量监测线程"""
|
||||||
if self._wifi_quality_monitor_thread is None:
|
with self._wifi_quality_lock:
|
||||||
|
t = self._wifi_quality_monitor_thread
|
||||||
|
if t is None:
|
||||||
|
return
|
||||||
|
if not t.is_alive():
|
||||||
|
self._wifi_quality_monitor_thread = None
|
||||||
return
|
return
|
||||||
|
|
||||||
self._wifi_quality_stop_event.set()
|
self._wifi_quality_stop_event.set()
|
||||||
try:
|
try:
|
||||||
self._wifi_quality_monitor_thread.join(timeout=2.0)
|
t.join(timeout=2.0)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
self.logger.error(f"[WiFi Monitor] 停止线程失败:{e}")
|
self.logger.error(f"[WiFi Monitor] 停止线程失败:{e}")
|
||||||
finally:
|
|
||||||
|
with self._wifi_quality_lock:
|
||||||
|
if t is self._wifi_quality_monitor_thread:
|
||||||
|
if t.is_alive():
|
||||||
|
self.logger.warning("[WiFi Monitor] 线程未在超时内退出,保留引用防止重复创建")
|
||||||
|
else:
|
||||||
self._wifi_quality_monitor_thread = None
|
self._wifi_quality_monitor_thread = None
|
||||||
self.logger.info("[WiFi Monitor] 已停止后台监测线程")
|
self.logger.info("[WiFi Monitor] 已停止后台监测线程")
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user