31 Commits
Author SHA1 Message Date
linyimin 8d35bf3068 feat: 2.18.10 靶规格复检与置信度门槛
- 检出靶框裁剪放大后二次 detect 复检(conf 0.25),以复检结果为准
- 全图结果上报门槛 MIN_REPORT_CONF=0.5,复检结果 >0.25 即采纳
- 0 结果时按 RETRY_CONF_TH 自动重检;class_id=1 解析阶段过滤
- 换用 model_326162,去除调试 print 改走 logger
2026-10-23 15:13:48 +08:00
linyimin d1a1d43bd8 test: 蓝牙主从测试脚本(bleak连接/bless GATT从机+bluetoothctl广播) 2026-10-22 22:52:23 +08:00
linyimin 496085cb93 feat: 2.18.9 靶规格YOLO分类重构
- 换用 model_325964(class_id: 0=40cm, 1=靶心, 2=20cm)
- class_id=1 解析阶段直接过滤,不参与上报选择
- 上报仅在有效靶规格(20/40)中取置信度最高的一条
- 0 结果时按 RETRY_CONF_TH 自动重检一次
- 增加检测调试打印(raw/detections/上报/忽略计数)
2026-10-22 18:13:47 +08:00
linyimin 2459bb3b67 fix: 2.18.6 去除打印日志 2026-10-22 10:14:49 +08:00
linyimin e6726f087c feat: 更新版本2.18.0 2026-09-23 16:43:04 +08:00
linyimin b8d000f5de feat: 1.json换为pb 2.wifi、ota 优化 2026-09-23 16:13:26 +08:00
linyimin 2834f5b9b7 feat: 2.17.18 2026-09-23 11:03:15 +08:00
linyimin e67c410325 fix: 摄像头翻转 2026-09-01 11:45:02 +08:00
linyimin 0c8ab1508f fix: 去除3秒内只能射箭一次的限制 2026-08-28 17:11:51 +08:00
yrx d30c432143 new model 317828 2026-08-28 16:04:24 +08:00
yrx c5338ccac7 new model 2026-08-28 15:15:21 +08:00
yrx 231937afba yolo最新选择 2026-08-28 14:57:56 +08:00
linyimin 70aa072164 fix: 压力改为增量触发 2026-08-19 17:31:05 +08:00
yrx 42026d43e5 模型调用 2026-08-17 15:49:01 +08:00
yrx 8a83deddd3 yolo模型 2026-08-14 16:32:17 +08:00
yrx 6a1d3fe2bd 整合yolo版本 2026-08-14 15:48:40 +08:00
linyimin 1fee464924 fix: 获取电量错误 2026-08-13 11:20:29 +08:00
linyimin 06994c5905 fix: 网络连接 2026-08-12 18:34:25 +08:00
linyimin 23755f48ae fix: 检测充电关机 2026-08-12 18:33:31 +08:00
linyimin 5f509488c5 fix: 触发 2026-08-11 13:20:59 +08:00
linyimin c0bb245c8c pref: 20cm靶子检测 2026-08-11 13:14:57 +08:00
linyimin 9cfc871645 pref: 删除无引用方法调用 2026-08-11 09:26:30 +08:00
linyimin 27f96d8bce fix: 优化射箭拍照慢问题 2026-08-10 12:06:01 +08:00
linyimin 80e780b931 fix: 关闭拍照图片的打印 2026-08-10 11:40:31 +08:00
linyimin 3683033abf pref: 拍照更快 2026-08-10 11:38:12 +08:00
linyimin f0df9ad915 fix: 重连时间设置更小 2026-07-31 14:09:11 +08:00
linyimin 3fcd38f417 fix: 4g通讯 2026-07-31 13:59:48 +08:00
linyimin abbf30d7c0 pref: wifi连接成功重新登录 2026-06-25 12:17:58 +08:00
linyimin 5cf752bb3f fix: maixcam wifi连接 2026-06-25 12:02:39 +08:00
linyimin 6d8de56bfa fix: maixcam wifi连接 2026-06-25 11:02:19 +08:00
linyimin aee1a92760 fix: wifi连接 2026-06-25 10:06:36 +08:00
34 changed files with 2405 additions and 1578 deletions
+3
View File
@@ -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.
+4 -3
View File
@@ -1,6 +1,6 @@
id: t11 id: t11
name: t11 name: t11
version: 2.15.15 version: 2.18.10
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_326162.cvimodel
- model_270139.mud - model_326162.mud
- network.py - network.py
- ota_curl.sh - ota_curl.sh
- ota_manager.py - ota_manager.py
@@ -28,6 +28,7 @@ files:
- shoot_manager.py - shoot_manager.py
- shot_id_generator.py - shot_id_generator.py
- target_roi_yolo.py - target_roi_yolo.py
- tcp_messages_pb2.py
- time_sync.py - time_sync.py
- triangle_positions.json - triangle_positions.json
- triangle_target.py - triangle_target.py
Binary file not shown.
+7 -6
View File
@@ -76,10 +76,11 @@ class ATClient:
""" """
expect_b = expect.encode() if isinstance(expect, str) else expect expect_b = expect.encode() if isinstance(expect, str) else expect
with self._cmd_lock: with self._cmd_lock:
# 初始化等待 with self._q_lock:
self._waiting = True # 初始化等待
self._expect = expect_b self._waiting = True
self._resp = b"" self._expect = expect_b
self._resp = b""
# 发送 # 发送
if cmd: if cmd:
@@ -300,8 +301,8 @@ class ATClient:
if len(self._rx) > 512 * 1024: if len(self._rx) > 512 * 1024:
self._rx = self._rx[-256 * 1024:] self._rx = self._rx[-256 * 1024:]
else: else:
if len(self._rx) > 16384: if len(self._rx) > 32768:
self._rx = self._rx[-4096:] self._rx = self._rx[-16384:]
+46 -6
View File
@@ -24,7 +24,7 @@ TRIANGLE_DETECT_SCALE = 0.4
# SERVER_IP = "stcp.shelingxingqiu.com" # SERVER_IP = "stcp.shelingxingqiu.com"
SERVER_IP = "www.shelingxingqiu.com" SERVER_IP = "www.shelingxingqiu.com"
SERVER_PORT = 50005 SERVER_PORT = 50005
HEARTBEAT_INTERVAL = 15 # 心跳间隔(秒) HEARTBEAT_INTERVAL = 5 # 心跳间隔(秒)
# WiFi 质量评估(开机先尝试 WiFi;质量差且 4G 可用则切到 4G,本次上电直至关机锁定 4G) # WiFi 质量评估(开机先尝试 WiFi;质量差且 4G 可用则切到 4G,本次上电直至关机锁定 4G)
WIFI_QUALITY_RTT_SAMPLES = 3 # 到业务服务器 TCP 建连耗时采样次数,取中位数 WIFI_QUALITY_RTT_SAMPLES = 3 # 到业务服务器 TCP 建连耗时采样次数,取中位数
@@ -48,7 +48,7 @@ WIFI_CONFIG_AP_IP = "192.168.66.1" # 与 MaixPy Wifi.start_ap 默认一
# ===== TCP over SSL(TLS) 配置 ===== # ===== TCP over SSL(TLS) 配置 =====
USE_TCP_SSL = True # True=按手册走 MSSLCFG/MIPCFG 绑定 SSL USE_TCP_SSL = True # True=按手册走 MSSLCFG/MIPCFG 绑定 SSL
TCP_LINK_ID = 2 # TCP_LINK_ID = 2 #
TCP_SSL_PORT = 50006 # TLS 端口(不一定必须 443,以服务器为准) TCP_SSL_PORT = 50007 # TLS 端口(不一定必须 443,以服务器为准)
# SSL profile # SSL profile
SSL_ID = 1 # ssl_id=1 SSL_ID = 1 # ssl_id=1
@@ -234,10 +234,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 +262,27 @@ 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_id → label,下标即 class_id:0=40cm, 1=0(靶心), 2=20cm.
TARGET_CLASS_YOLO_ENABLE = True
TARGET_CLASS_YOLO_MODEL_PATH = APP_DIR + "/model_326162.mud"
TARGET_CLASS_YOLO_LABELS = (40, 0, 20)
# 只有映射结果落在该集合内才用于上报(如 class 1→label 0 直接忽略)
TARGET_CLASS_YOLO_VALID_LABELS = (20, 40)
# 解析阶段直接丢弃的 class_id(1=靶心),不参与任何选择/上报
TARGET_CLASS_YOLO_IGNORE_CLASS_IDS = (1,)
TARGET_CLASS_YOLO_CONF_TH = 0.5
# 首次检测 0 结果时用该阈值重新检测一次(≥CONF_TH 时按 CONF_TH 同参重跑)
TARGET_CLASS_YOLO_RETRY_CONF_TH = 0.25
# 检出靶框后裁剪放大再 detect 一次,与全图结果互相印证(不一致以复检为准)
TARGET_CLASS_YOLO_RECHECK_CROP = True
TARGET_CLASS_YOLO_RECHECK_MARGIN_FRAC = 0.1
# 复检的 detect 阈值与接受门槛:置信度大于该值即采纳复检结果
TARGET_CLASS_YOLO_RECHECK_CONF_TH = 0.25
# 全图结果(无复检时)的上报置信度门槛,None/0 表示不启用
TARGET_CLASS_YOLO_MIN_REPORT_CONF = 0.5
TARGET_CLASS_YOLO_IOU_TH = 0.45
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)。
@@ -316,9 +337,13 @@ LOG_QUEUE_MAXSIZE = 10000 # 日志队列上限
MAX_CMD_THREADS = 10 # 并发命令线程上限(防止服务器下发命令时无限创建线程) MAX_CMD_THREADS = 10 # 并发命令线程上限(防止服务器下发命令时无限创建线程)
# ==================== 图像保存配置 ==================== # ==================== 图像保存配置 ====================
SAVE_IMAGE_ENABLED = False # 是否保存图像(True=保存,False=不保存) SAVE_IMAGE_ENABLED = True # 是否保存图像(True=保存,False=不保存)
SAVE_IMAGE_ON_FAILURE = True # 检测失败时是否强制保存图像(供调试测试用)
PHOTO_DIR = "/root/phot" # 照片存储目录 PHOTO_DIR = "/root/phot" # 照片存储目录
MAX_IMAGES = 1000 MAX_IMAGES = 1000
SAVE_RAW_IMAGE_ENABLED = False # 额外保存完整原始帧(不画框、不画点、不裁剪)
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
@@ -341,7 +366,22 @@ PIN_MAPPINGS = {
} }
# ==================== 电源配置 ==================== # ==================== 电源配置 ====================
AUTO_POWER_OFF_IN_SECONDS = 10 * 60 # 自动关机时间(秒),0表示不自动关机 AUTO_POWER_OFF_IN_SECONDS = 100 * 60 # 自动关机时间(秒),0表示不自动关机
# 充电时自动关机暂时禁用;需要恢复时改为 True。
CHARGING_AUTO_POWER_OFF_ENABLED = False
# 一代电源控制:A24 由电源板负责按键/关机信号,软件关机时输出高电平。
# 电源状态指示灯
STATUS_LED_GREEN_GPIO = "GPIOA25"
STATUS_LED_RED_GPIO = "GPIOA23"
STATUS_LED_GREEN_ENABLED = True
STATUS_LED_RED_ENABLED = True
STATUS_LED_ACTIVE_LEVEL = 1
STATUS_LED_LOW_BATTERY_PERCENT = 10
STATUS_LED_FULL_BATTERY_PERCENT = 90
STATUS_LED_CHARGING_BLINK_MS = 500
STATUS_LED_POLL_MS = 1000
BATTERY_SOC_LPF_ALPHA = 0.5 BATTERY_SOC_LPF_ALPHA = 0.5
BATTERY_SOC_AVG_WINDOW = 5 BATTERY_SOC_AVG_WINDOW = 5
+154 -34
View File
@@ -13,7 +13,9 @@ from maix import camera, display, image, app, time, uart, pinmap, i2c
from maix.peripheral import adc from maix.peripheral import adc
import _thread import _thread
import os import os
import sys
import json import json
import shutil
import time as wall_time import time as wall_time
# 导入新模块 # 导入新模块
@@ -120,9 +122,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 +134,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 +165,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 +254,61 @@ 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. 创建照片存储目录(如果启用图像保存) # 4.1 检查是否有 OTA 待更新文件(从临时目录移动到实际目录)
if config.SAVE_IMAGE_ENABLED: staging_dir = f"{config.APP_DIR}/ota_staging"
if os.path.exists(staging_dir):
try:
moved_count = 0
for root, dirs, files in os.walk(staging_dir):
for f in files:
src = os.path.join(root, f)
rel = os.path.relpath(src, staging_dir)
dest = os.path.join(config.APP_DIR, rel)
dest_dir = os.path.dirname(dest)
if dest_dir:
try:
os.makedirs(dest_dir, exist_ok=True)
except:
pass
try:
shutil.copy2(src, dest)
moved_count += 1
except Exception as e:
if logger:
logger.error(f"[OTA] 移动文件失败 {rel}: {e}")
# 删除临时目录
try:
shutil.rmtree(staging_dir, ignore_errors=True)
except:
pass
if logger:
logger.info(f"[OTA] 已从临时目录更新 {moved_count} 个文件,重启应用...")
# 清理硬件资源,然后重启应用
try:
laser_manager.turn_off_laser()
except:
pass
try:
camera_manager.release()
except:
pass
try:
os.sync()
except:
pass
import sys
os.execv(sys.executable, [sys.executable, os.path.join(config.APP_DIR, "main.py")])
return
except Exception as e:
if logger:
logger.error(f"[OTA] 处理临时目录失败: {e}")
# 5. 创建照片存储目录(如果启用图像保存或检测失败时强制保存)
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,42 +340,44 @@ def cmd_str():
logger.info("系统准备完成...") logger.info("系统准备完成...")
last_adc_trigger = 0 last_adc_trigger = 0
trigger_adc_val = 0 # 触发时的气压值,气压需降回此值以下才能再次触发
# 读取一次ADC初始值,防止开机时传感器已有压力导致误触发
enable_check = True
_should_reboot = False
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):
nonlocal pressure_buf, pressure_sum, pressure_min, pressure_max, pressure_t0_ms, logger, pressure_abs_sum, last_avg_abs nonlocal pressure_buf, pressure_sum, pressure_min, pressure_max, pressure_t0_ms, logger
if not pressure_buf: if not pressure_buf:
return return
if config.AIR_PRESSURE_lOG: 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)
last_avg_abs = avg_abs
# 无论是否记录日志,都必须清空 buffer,否则内存泄漏 # 无论是否记录日志,都必须清空 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
@@ -335,6 +399,14 @@ def cmd_str():
time.sleep_ms(250) time.sleep_ms(250)
continue continue
# OTA 完成后需要重启,从主循环退出(由启动时 staging 检测处理重启)
if network_manager.ota_restart_pending:
network_manager.ota_restart_pending = False
_should_reboot = True
if logger:
logger.info("[MAIN] OTA重启标志已设置,退出主循环...")
break
# 不在 OTA 状态下,检测是否空闲足够长,自动关机 # 不在 OTA 状态下,检测是否空闲足够长,自动关机
# print(f"[MAIN] 空闲时间: {hardware_manager.get_idle_time_in_sec() }秒") # print(f"[MAIN] 空闲时间: {hardware_manager.get_idle_time_in_sec() }秒")
# print(f"配置关机时间:{config.AUTO_POWER_OFF_IN_SECONDS} 秒") # print(f"配置关机时间:{config.AUTO_POWER_OFF_IN_SECONDS} 秒")
@@ -351,12 +423,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:
@@ -367,25 +437,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: # 突变增量检测:压力增量大于400时触发
# 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) > 200 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:
@@ -403,10 +472,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:
# 主循环的顶层异常捕获,防止程序静默退出 # 主循环的顶层异常捕获,防止程序静默退出
@@ -424,10 +492,62 @@ def cmd_str():
_flush_pressure_buf("exception") _flush_pressure_buf("exception")
except: except:
pass pass
time.sleep_ms(1000) # 等待1秒后继续 time.sleep_ms(1000) # 等待1秒后 continue
# 主循环退出后,如果是由 OTA 触发的,移动 staging 文件并重启应用
if _should_reboot:
staging_dir = f"{config.APP_DIR}/ota_staging"
if os.path.exists(staging_dir):
try:
moved_count = 0
for root, dirs, files in os.walk(staging_dir):
for f in files:
src = os.path.join(root, f)
rel = os.path.relpath(src, staging_dir)
dest = os.path.join(config.APP_DIR, rel)
dest_dir = os.path.dirname(dest)
if dest_dir:
try:
os.makedirs(dest_dir, exist_ok=True)
except:
pass
try:
shutil.copy2(src, dest)
moved_count += 1
except Exception as e:
if logger:
logger.error(f"[OTA] 移动文件失败 {rel}: {e}")
try:
shutil.rmtree(staging_dir, ignore_errors=True)
except:
pass
if logger:
logger.info(f"[MAIN] OTA 更新完成,已应用 {moved_count} 个文件,重启应用...")
except Exception as e:
if logger:
logger.error(f"[OTA] 处理 staging 目录失败: {e}")
else:
if logger:
logger.info("[MAIN] OTA 更新完成,重启应用...")
# 清理硬件资源,然后重启应用(不重启设备)
try:
laser_manager.turn_off_laser()
except:
pass
try:
camera_manager.release()
except:
pass
try:
hardware_manager.stop_idle_timer()
except:
pass
try:
os.sync()
except:
pass
import sys
os.execv(sys.executable, [sys.executable, os.path.join(config.APP_DIR, "main.py")])
# 主程序入口 # 主程序入口
-13
View File
@@ -1,13 +0,0 @@
[basic]
type = cvimodel
model = model_270139.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 = 黑三角和圆环
Binary file not shown.
Binary file not shown.
+2 -2
View File
@@ -1,7 +1,7 @@
[basic] [basic]
type = cvimodel type = cvimodel
model = model_270820.cvimodel model = model_326162.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 = 40, 00, 20
+510 -302
View File
File diff suppressed because it is too large Load Diff
+146 -1172
View File
File diff suppressed because it is too large Load Diff
+11 -10
View File
@@ -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,8 +86,8 @@ 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
Current_LSB = 0.001 × CALIBRATION_VALUE / 4096 Current_LSB = 0.001 × CALIBRATION_VALUE / 4096
@@ -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
@@ -119,17 +120,17 @@ def get_current():
def is_charging(threshold_ma=10.0): def is_charging(threshold_ma=10.0):
""" """
检测是否在充电(通过电流方向判断) 检测是否在充电(通过电流方向判断)
Args: Args:
threshold_ma: 电流阈值(毫安),超过此值认为在充电,默认10mA threshold_ma: 电流阈值(毫安),超过此值认为在充电,默认10mA
Returns: Returns:
True: 正在充电 True: 正在充电
False: 未充电或读取失败 False: 未充电或读取失败
""" """
try: try:
current = get_current() current = get_current()
is_charge = current > threshold_ma is_charge = current < -abs(float(threshold_ma))
return is_charge return is_charge
except Exception as e: except Exception as e:
logger = logger_manager.logger logger = logger_manager.logger
@@ -159,7 +160,7 @@ def voltage_to_percent(voltage):
return 0 return 0
if v <= 0: if v <= 0:
return 0 return 0
return int(int(_BATTERY_MONITOR.get_soc(v) * 10) / 10) # 截断而不是四舍五入 return int(int(_BATTERY_MONITOR.get_soc(v) * 10) / 10) # 截断而不是四舍五入
class BatteryMonitor: class BatteryMonitor:
+84 -12
View File
@@ -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
# 缓存相机标定与三角形位置,避免每次射箭重复读磁盘 # 缓存相机标定与三角形位置,避免每次射箭重复读磁盘
@@ -54,6 +59,7 @@ def analyze_shot(frame, laser_point=None):
""" """
logger = logger_manager.logger logger = logger_manager.logger
from datetime import datetime from datetime import datetime
yellow_algorithm_ms = 0.0
# ── Step 1: 确定激光点 ──────────────────────────────────────────────────── # ── Step 1: 确定激光点 ────────────────────────────────────────────────────
laser_point_method = None laser_point_method = None
@@ -69,7 +75,11 @@ def analyze_shot(frame, laser_point=None):
logger.info(f"[算法] 使用校准值: {laser_manager.laser_point}") logger.info(f"[算法] 使用校准值: {laser_manager.laser_point}")
else: else:
# 动态模式:先做一次无激光点检测以估算距离,再推算激光点 # 动态模式:先做一次无激光点检测以估算距离,再推算激光点
_, _, _, _, best_radius1_temp, _ = detect_circle_v3(frame, None) _t_yellow = time_std.perf_counter()
try:
_, _, _, _, best_radius1_temp, _ = detect_circle_v3(frame, None)
finally:
yellow_algorithm_ms += (time_std.perf_counter() - _t_yellow) * 1000.0
distance_m_first = estimate_distance(best_radius1_temp) if best_radius1_temp else None distance_m_first = estimate_distance(best_radius1_temp) if best_radius1_temp else None
if distance_m_first and distance_m_first > 0: if distance_m_first and distance_m_first > 0:
laser_point = laser_manager.calculate_laser_point_from_distance(distance_m_first) laser_point = laser_manager.calculate_laser_point_from_distance(distance_m_first)
@@ -114,6 +124,7 @@ def analyze_shot(frame, laser_point=None):
"laser_point": laser_point, "laser_point_method": laser_point_method, "laser_point": laser_point, "laser_point_method": laser_point_method,
"offset_method": "yellow_ellipse" if ellipse_params else "yellow_circle", "offset_method": "yellow_ellipse" if ellipse_params else "yellow_circle",
"distance_method": "yellow_radius", "distance_method": "yellow_radius",
"yellow_algorithm_ms": float(yellow_algorithm_ms),
} }
if yolo_roi_xyxy is not None: if yolo_roi_xyxy is not None:
out["yolo_roi_xyxy"] = yolo_roi_xyxy out["yolo_roi_xyxy"] = yolo_roi_xyxy
@@ -121,9 +132,12 @@ def analyze_shot(frame, laser_point=None):
if not use_tri: if not use_tri:
# 三角形未配置,直接跑圆形检测 # 三角形未配置,直接跑圆形检测
return _build_circle_result( _t_yellow = time_std.perf_counter()
detect_circle_v3(frame, laser_point, img_cv=img_cv) try:
) cdata = detect_circle_v3(frame, laser_point, img_cv=img_cv)
finally:
yellow_algorithm_ms += (time_std.perf_counter() - _t_yellow) * 1000.0
return _build_circle_result(cdata)
# ── Step 4: 先独占跑三角形,超时或失败后再跑圆形(不与圆心并行,避免抢 CPU)── # ── Step 4: 先独占跑三角形,超时或失败后再跑圆形(不与圆心并行,避免抢 CPU)──
roi_xyxy = None roi_xyxy = None
@@ -276,6 +290,7 @@ def analyze_shot(frame, laser_point=None):
"laser_point": laser_point, "laser_point_method": laser_point_method, "laser_point": laser_point, "laser_point_method": laser_point_method,
"offset_method": tri.get("offset_method") or "triangle_homography", "offset_method": tri.get("offset_method") or "triangle_homography",
"distance_method": tri.get("distance_method") or "pnp_triangle", "distance_method": tri.get("distance_method") or "pnp_triangle",
"yellow_algorithm_ms": float(yellow_algorithm_ms),
"tri_markers": tri.get("markers", []), "tri_markers": tri.get("markers", []),
"tri_markers_completed": tri.get("markers_completed", []), "tri_markers_completed": tri.get("markers_completed", []),
"tri_homography": tri.get("homography"), "tri_homography": tri.get("homography"),
@@ -296,7 +311,11 @@ def analyze_shot(frame, laser_point=None):
# 三角形超时或失败 → 跑圆心;圆心跑完后再检查三角形是否已结束 # 三角形超时或失败 → 跑圆心;圆心跑完后再检查三角形是否已结束
try: try:
cdata = detect_circle_v3(frame, laser_point, img_cv=img_cv) _t_yellow = time_std.perf_counter()
try:
cdata = detect_circle_v3(frame, laser_point, img_cv=img_cv)
finally:
yellow_algorithm_ms += (time_std.perf_counter() - _t_yellow) * 1000.0
except Exception as e: except Exception as e:
logger.error(f"[CIRCLE] 圆形检测异常: {e}") logger.error(f"[CIRCLE] 圆形检测异常: {e}")
cdata = (frame, None, None, None, None, None) cdata = (frame, None, None, None, None, None)
@@ -320,9 +339,33 @@ 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
yolo_target_ms = 0.0
try:
from target_roi_yolo import try_get_target_class_from_yolo
_t_yolo_target = time_std.perf_counter()
try:
target_class_result = try_get_target_class_from_yolo(frame, logger=logger)
finally:
yolo_target_ms = (time_std.perf_counter() - _t_yolo_target) * 1000.0
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)
@@ -346,6 +389,7 @@ def process_shot(adc_val):
laser_point_method = analysis_result["laser_point_method"] laser_point_method = analysis_result["laser_point_method"]
offset_method = analysis_result.get("offset_method", "yellow_circle") offset_method = analysis_result.get("offset_method", "yellow_circle")
distance_method = analysis_result.get("distance_method", "yellow_radius") distance_method = analysis_result.get("distance_method", "yellow_radius")
yellow_algorithm_ms = float(analysis_result.get("yellow_algorithm_ms", 0.0) or 0.0)
tri_markers = analysis_result.get("tri_markers", []) tri_markers = analysis_result.get("tri_markers", [])
tri_markers_completed = analysis_result.get("tri_markers_completed", []) tri_markers_completed = analysis_result.get("tri_markers_completed", [])
tri_homography = analysis_result.get("tri_homography") tri_homography = analysis_result.get("tri_homography")
@@ -366,10 +410,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 +422,27 @@ 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": (
round(float(target_confidence), 2)
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,
@@ -397,6 +453,8 @@ def process_shot(adc_val):
"target_y": float(y), "target_y": float(y),
"offset_method": offset_method, "offset_method": offset_method,
"distance_method": distance_method, "distance_method": distance_method,
"yellow_algorithm_ms": round(yellow_algorithm_ms, 2),
"yolo_target_ms": round(float(yolo_target_ms), 2),
} }
if ellipse_params: if ellipse_params:
@@ -413,7 +471,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 +588,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 +598,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:
+333 -4
View File
@@ -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,284 @@ 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):
"""调用 YOLO 检测:class_id∈IGNORE_CLASS_IDS(默认 1) 的框在解析时直接过滤;
detections 输出其余全部框(不按置信度过滤);
上报以「检出框裁剪放大后的复检结果」为主,复检不可用时用全图结果;
有效靶规格(20/40)中取置信度最高的一条,且低于 MIN_REPORT_CONF 不上报类别。"""
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))
# class_id → 靶规格标签,下标即 class_id:0→40, 1→0, 2→20
labels = getattr(cfg, "TARGET_CLASS_YOLO_LABELS", (40, 0, 20))
if isinstance(labels, str):
labels = tuple(x.strip() for x in labels.split(",") if x.strip())
labels = tuple(labels)
# 只有映射到这些标签的检测才算有效靶规格(class 1→label 0 直接忽略)
valid_labels = getattr(cfg, "TARGET_CLASS_YOLO_VALID_LABELS", (20, 40))
if isinstance(valid_labels, str):
valid_labels = tuple(
int(float(x.strip())) for x in valid_labels.split(",") if x.strip()
)
valid_labels = frozenset(int(float(v)) for v in valid_labels)
# 解析阶段就丢弃这些 class_id(1=靶心),不进入 detections,也不参与上报选择
ignore_class_ids = getattr(cfg, "TARGET_CLASS_YOLO_IGNORE_CLASS_IDS", (1,))
if isinstance(ignore_class_ids, int):
ignore_class_ids = (ignore_class_ids,)
if isinstance(ignore_class_ids, str):
ignore_class_ids = tuple(
int(float(x.strip())) for x in ignore_class_ids.split(",") if x.strip()
)
ignore_class_ids = frozenset(int(float(v)) for v in ignore_class_ids)
n_ignored = [0]
def _run(threshold):
try:
return 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 None
def _obj_box(o):
try:
return (
round(float(o.x), 1),
round(float(o.y), 1),
round(float(o.w), 1),
round(float(o.h), 1),
)
except (TypeError, ValueError, AttributeError):
return None
def _parse(raw_result):
out = []
n_ignored[0] = 0
for obj in _normalize_objs(raw_result if raw_result is not None else []):
class_id = _det_obj_class_id(obj)
if class_id is None or class_id in ignore_class_ids:
n_ignored[0] += 1
continue
label = None
if 0 <= class_id < len(labels):
try:
label = int(float(labels[class_id]))
except (TypeError, ValueError):
label = None
out.append(
{
"class_id": class_id,
"label": label,
"confidence": round(_det_obj_score(obj), 4),
"box_xywh": _obj_box(obj),
}
)
return out
# 第 1 次检测
raw = _run(conf_th)
detections = _parse(raw)
# 0 结果时重新检测一次(阈值取更小值,仍为 0 则同参数重跑)
if not detections:
retry_th = float(getattr(cfg, "TARGET_CLASS_YOLO_RETRY_CONF_TH", 0.25) or 0)
if not (0 < retry_th < conf_th):
retry_th = conf_th
if logger:
logger.info(f"[YOLO-TARGET] 0 结果,重新检测 conf_th={retry_th}")
raw_retry = _run(retry_th)
detections_retry = _parse(raw_retry)
if detections_retry:
detections = detections_retry
if logger:
logger.info(
f"[YOLO-TARGET] 检测结果={detections}"
f"(已忽略 class_id∈{tuple(sorted(ignore_class_ids))} 共{n_ignored[0]} 条)"
)
if not detections:
if logger:
logger.warning("[YOLO-TARGET] 未检测到目标(已重检 1 次)")
return None
def _best_valid(dets):
valid = [d for d in dets if d["label"] in valid_labels]
if not valid:
return None
return max(valid, key=lambda d: d["confidence"])
def _crop_target(box, margin_frac):
"""按检出框(带 margin)裁出放大 ROI;返回 Maix image,失败返回 None。"""
if not box or len(box) != 4:
return None
x, y, w, h = [float(v) for v in box]
try:
fw, fh = int(maix_frame.width()), int(maix_frame.height())
except Exception:
fw = fh = 0
mx, my = w * margin_frac, h * margin_frac
x0, y0 = int(max(0, x - mx)), int(max(0, y - my))
x1 = int(min(fw, x + w + mx)) if fw else int(x + w + mx)
y1 = int(min(fh, y + h + my)) if fh else int(y + h + my)
if (x1 - x0) < 32 or (y1 - y0) < 32:
if logger:
logger.warning(
f"[YOLO-TARGET] 复检裁剪过小 [{x0},{y0},{x1},{y1}]"
)
return None
try:
cropped = maix_frame.crop(x0, y0, x1 - x0, y1 - y0)
if cropped is not None:
return cropped
except Exception:
pass
try:
from maix import image as maix_image
arr = maix_frame.to_numpy()
slab = np.ascontiguousarray(arr[y0:y1, x0:x1], dtype=np.uint8)
if slab.size == 0:
return None
return maix_image.cv2image(slab, False, True)
except Exception as exc:
if logger:
logger.warning(f"[YOLO-TARGET] 复检裁剪失败: {exc}")
return None
# 上报口径:只在有效靶规格(20/40)里取置信度最高的一条,class_id=1 已在解析阶段过滤
full_best = _best_valid(detections)
# 二次裁剪复检:把全图检出的靶框裁剪放大后再 detect 一次,与全图结果互相印证
# 复检用更低阈值 RECHECK_CONF_TH,置信度大于该值即接受
recheck_th = float(getattr(cfg, "TARGET_CLASS_YOLO_RECHECK_CONF_TH", 0.25))
crop_best = None
recheck_status = "off"
if full_best is not None and bool(
getattr(cfg, "TARGET_CLASS_YOLO_RECHECK_CROP", True)
):
recheck_status = "fail"
margin_frac = float(
getattr(cfg, "TARGET_CLASS_YOLO_RECHECK_MARGIN_FRAC", 0.1)
)
crop_img = _crop_target(full_best["box_xywh"], margin_frac)
if crop_img is not None:
try:
raw_crop = detector.detect(
crop_img, conf_th=recheck_th, iou_th=iou_th
)
except Exception as exc:
raw_crop = None
if logger:
logger.warning(f"[YOLO-TARGET] 复检 detect 异常: {exc}")
if raw_crop is not None:
crop_dets = _parse(raw_crop)
recheck_status = "ok"
crop_best = _best_valid(crop_dets)
if crop_best is not None and crop_best["confidence"] <= recheck_th:
crop_best = None
if logger:
logger.info(
f"[YOLO-TARGET] 复检(conf_th={recheck_th}, "
f"裁剪框={full_best['box_xywh']}) 结果={crop_dets}"
)
# 合并:以复检(裁剪放大)结果为主;复检无结果才用全图
picked = full_best
picked_from_crop = False
note = ""
if recheck_status == "ok":
if crop_best is None:
note = f"复检无 conf>{recheck_th} 的有效框,沿用全图"
else:
picked = crop_best
picked_from_crop = True
if crop_best["label"] == full_best["label"]:
note = f"复检一致({crop_best['label']}),以复检为准"
else:
note = (
f"复检与全图不一致(全图={full_best['label']}@{full_best['confidence']}, "
f"裁剪={crop_best['label']}@{crop_best['confidence']}),以复检为准"
)
# 置信度门槛:低于阈值不上报类别,避免硬猜(复检结果只需 > RECHECK_CONF_TH)
if picked is not None:
if picked_from_crop:
min_report_conf = recheck_th
below = picked["confidence"] <= min_report_conf
else:
min_report_conf = float(
getattr(cfg, "TARGET_CLASS_YOLO_MIN_REPORT_CONF", 0.5)
)
below = picked["confidence"] < min_report_conf
if below:
note = (note + "; " if note else "") + (
f"conf={picked['confidence']} 未达门槛{min_report_conf},置信度不足"
)
picked = None
if picked is None:
label, class_id, confidence = None, None, None
else:
label = picked["label"]
class_id = picked["class_id"]
confidence = picked["confidence"]
if logger:
logger.info(
f"[YOLO-TARGET] 上报={label}, class_id={class_id}, conf={confidence}, "
f"全图候选={[(d['label'], d['confidence']) for d in detections if d['label'] in valid_labels]}, "
f"复检={recheck_status} {note}"
)
if note:
logger.warning(f"[YOLO-TARGET] {note}")
return {
"label": label,
"class_id": class_id,
"confidence": confidence,
"detections": detections,
}
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)
+355
View File
File diff suppressed because one or more lines are too long
+135
View File
@@ -0,0 +1,135 @@
#!/usr/bin/env python3
# test_bluetooth.py
# 参考: https://wiki.sipeed.com/maixpy/doc/zh/modules/bluetooth.html
#
# 依赖:
# 1. 使能蓝牙: bluetoothctl power on
# (可选开机自启: echo "bluetoothctl power on" >> /etc/rc.local)
# 2. 安装依赖: pip install bleak
import asyncio
import sys
from collections import namedtuple
try:
from bleak import BleakScanner, BleakClient
except ImportError:
print("ModuleNotFoundError: No module named 'bleak'")
print("请先在设备上安装依赖:")
print(" pip install bleak")
print("并使能蓝牙:")
print(" bluetoothctl power on")
sys.exit(1)
MODEL_NBR_UUID = "1A2A"
BLEDev = namedtuple("BLEDev", ["address", "name"])
async def scan_devices(timeout=5.0):
"""扫描周围的蓝牙设备"""
print("=" * 60)
print("Scanning BLE devices...")
print("=" * 60)
found = {}
def _cb(device, adv):
found[device.address] = adv.local_name or device.name
try:
await BleakScanner.discover(timeout=timeout, detection_callback=_cb)
except Exception as e:
print(f"Scan failed: {e}")
print("Possible causes:")
print(" 1. Bluetooth not powered on: bluetoothctl power on")
print(" 2. bleak not installed: pip install bleak")
print(" 3. BLE hardware/driver not available")
return []
if not found:
print("No devices found.")
return []
devices = []
for address, name in found.items():
devices.append(BLEDev(address, name))
print(f" {address} {name}")
return devices
async def connect_device(address, timeout=20.0, retries=3):
"""连接蓝牙设备并尝试读取特征值"""
print("=" * 60)
print(f"Connecting to {address} ...")
print("=" * 60)
for attempt in range(1, retries + 1):
try:
async with BleakClient(address, timeout=timeout) as client:
print(f"Connected: {client.is_connected}")
# 遍历服务和特征值
for service in client.services:
print(f" Service: {service.uuid}")
for char in service.characteristics:
print(f" Char: {char.uuid} props={list(char.properties)}")
# 尝试读取 Model Number 特征
try:
value = await client.read_gatt_char(MODEL_NBR_UUID)
print(f"Model Number: {value.decode(errors='replace')}")
except Exception as e:
print(f"Read {MODEL_NBR_UUID} failed (device may not have it): {e}")
return True
except Exception as e:
print(f"[attempt {attempt}/{retries}] Connect failed: {e}")
if attempt < retries:
await asyncio.sleep(2)
print("\nAll attempts failed. 请检查:")
print(" 1. forrily 是否已连到手机/其它主机(BLE 外设通常只允许一连接),")
print(" 先在手机上断开它")
print(" 2. 清除 BlueZ 缓存后重试:")
print(f" bluetoothctl remove {address}")
print(" systemctl restart bluetooth")
print(" bluetoothctl power on")
print(" 3. 升级依赖:")
print(" pip install -U bleak dbus-fast")
return False
async def main():
target_name = "forrily"
devices = await scan_devices(timeout=5.0)
if not devices:
return
if len(sys.argv) > 1:
address = sys.argv[1]
else:
# 按名字查找目标设备,不连第一个
target = None
for d in devices:
if d.name and d.name.strip().lower() == target_name.lower():
target = d
break
if target is None:
for d in devices:
if d.name and target_name.lower() in d.name.strip().lower():
target = d
break
if target is None:
print(f"\nDevice named '{target_name}' not found!")
print("Scanned devices are listed above.")
return
address = target.address
print(f"\nFound '{target_name}': {address}")
await connect_device(address)
if __name__ == "__main__":
asyncio.run(main())
+239
View File
@@ -0,0 +1,239 @@
#!/usr/bin/env python3
# test_bluetooth_peripheral.py
# 设备(MaixCAM)作为 BLE 从机(Peripheral/GATT Server)广播给手机连接。
# 参考: https://wiki.sipeed.com/maixpy/doc/zh/modules/bluetooth.html
#
# 用法:
# python test_bluetooth_peripheral.py [广播名字] # 默认 shoot
#
# 依赖:
# 1. 使能蓝牙: bluetoothctl power on
# 2. 安装依赖: pip install bless
# 3. 部分 BlueZ 需要 experimental 模式才支持 GATT Server:
# 编辑 /etc/bluetooth/main.conf 保证有 Experimental = true
# 然后 systemctl restart bluetooth && bluetoothctl power on
#
# 手机端: 用 nRF Connect / 任意 BLE 调试 App 搜索广播名连接,
# 读写 characteristic 并订阅 notify,每 2 秒会收到计数通知。
#
# 实现:
# - GATT 服务由 bless 注册到 BlueZ
# - 广播由常驻 bluetoothctl 子进程控制 (menu advertise / name / advertise on),
# 这是已验证手机能搜到的路径; 若 bluetoothctl 不可用则回退到 btmgmt adv on
import asyncio
import shutil
import subprocess
import sys
import time
try:
from bless import (
BlessServer,
GATTCharacteristicProperties,
GATTAttributePermissions,
)
except ImportError:
print("ModuleNotFoundError: No module named 'bless'")
print("请先在设备上安装依赖:")
print(" pip install bless")
print("并使能蓝牙:")
print(" bluetoothctl power on")
raise SystemExit(1)
DEVICE_NAME = sys.argv[1] if len(sys.argv) > 1 else "shoot"
SERVICE_UUID = "A07498CA-AD5B-474E-940D-16F1FBE7E8CD"
CHAR_UUID = "51FF12BB-3ED8-46E5-B4F9-D64E2FEC021B"
BTCTL_LOG = "/tmp/btctl_adv.log"
# 不同 bless 版本枚举名不同: writeable / writable
try:
WRITE_PERMISSION = GATTAttributePermissions.writeable
except AttributeError:
WRITE_PERMISSION = GATTAttributePermissions.writable
_btctl = None
def on_read(characteristic, request):
print(f"[GATT] read {characteristic.uuid}")
return characteristic.value
def on_write(characteristic, value, request):
characteristic.value = value
print(f"[GATT] write {characteristic.uuid} = {bytes(value)}")
def on_subscribe(characteristic, session):
print(f"[GATT] client subscribed: {characteristic.uuid}")
def on_unsubscribe(characteristic, session):
print(f"[GATT] client unsubscribed: {characteristic.uuid}")
def _run(cmd):
try:
r = subprocess.run(cmd, capture_output=True, text=True, timeout=10)
out = ((r.stdout or "") + (r.stderr or "")).strip()
if out:
print(f"[adv] {' '.join(cmd)} -> {out}")
return r.returncode == 0
except FileNotFoundError:
print(f"[adv] {cmd[0]} not found")
return False
except Exception as e:
print(f"[adv] {' '.join(cmd)} failed: {e}")
return False
def adv_start(name):
"""启动广播: 优先 bluetoothctl (常驻子进程), 失败则回退 btmgmt"""
global _btctl
if shutil.which("bluetoothctl") is not None:
try:
log = open(BTCTL_LOG, "w")
_btctl = subprocess.Popen(
["bluetoothctl"],
stdin=subprocess.PIPE,
stdout=log,
stderr=subprocess.STDOUT,
text=True,
bufsize=1,
)
except Exception as e:
print(f"[adv] start bluetoothctl failed: {e}")
_btctl = None
if _btctl is not None:
for cmd in (
"menu advertise",
f"name {name}",
"discoverable on",
"menu main",
"advertise on",
):
try:
_btctl.stdin.write(cmd + "\n")
_btctl.stdin.flush()
except Exception as e:
print(f"[adv] write '{cmd}' failed: {e}")
break
time.sleep(0.3)
time.sleep(1.0)
if _btctl.poll() is None:
print(f"[adv] bluetoothctl advertising on (log: {BTCTL_LOG})")
return True
print("[adv] bluetoothctl exited early, fallback to btmgmt")
# 回退: mgmt 传统广播
if shutil.which("btmgmt") is None:
print("[adv] bluetoothctl/btmgmt 均不可用")
return False
_run(["btmgmt", "le", "on"])
_run(["btmgmt", "connectable", "on"])
return _run(["btmgmt", "adv", "on"])
def adv_stop():
global _btctl
if _btctl is not None:
try:
_btctl.stdin.write("advertise off\n")
_btctl.stdin.flush()
time.sleep(0.3)
_btctl.terminate()
except Exception:
pass
_btctl = None
elif shutil.which("btmgmt") is not None:
_run(["btmgmt", "adv", "off"])
async def run():
gatt = {
SERVICE_UUID: {
CHAR_UUID: {
"Properties": (
GATTCharacteristicProperties.read
| GATTCharacteristicProperties.write
| GATTCharacteristicProperties.notify
),
"Permissions": (
GATTAttributePermissions.readable | WRITE_PERMISSION
),
"Value": bytearray(b"hello from maixcam"),
}
}
}
server = BlessServer(
name=DEVICE_NAME,
on_read=on_read,
on_write=on_write,
on_subscribe=on_subscribe,
on_unsubscribe=on_unsubscribe,
)
print("Adding GATT ...")
await server.add_gatt(gatt)
print(f"Starting BLE peripheral, name = '{DEVICE_NAME}' ...")
try:
await server.start()
except Exception as e:
print(f"start() failed: {e}")
print("排查:")
print(" 1. 蓝牙是否上电: bluetoothctl power on (bluetoothctl show 看 Powered)")
print(" 2. /etc/bluetooth/main.conf 加 Experimental = true 后重启:")
print(" systemctl restart bluetooth && bluetoothctl power on")
print(" 3. 依赖: pip install -U bless dbus-next bleak")
return
adv = await server.is_advertising()
print("=" * 60)
print(f"'{DEVICE_NAME}' advertising status: {adv}")
print("Starting advertising ...")
if not adv_start(DEVICE_NAME):
print("广播启动失败,请手动执行: bluetoothctl -> advertise on")
print("手机用 nRF Connect 等 App 搜索并连接,")
print(f"服务 {SERVICE_UUID}")
print(f"特征 {CHAR_UUID} (read/write/notify)")
print("Ctrl+C 退出")
print("=" * 60)
try:
# 每 2 秒向订阅者推送一次计数
counter = 0
while True:
counter += 1
try:
char = server.get_characteristic(CHAR_UUID)
if char is not None:
char.value = bytearray(str(counter).encode())
server.update_value(SERVICE_UUID, CHAR_UUID)
except Exception as e:
# 没有客户端订阅时可能失败,忽略
print(f"notify failed: {e}")
if counter % 10 == 0:
try:
print(f"[status] advertising={await server.is_advertising()} "
f"connected={await server.is_connected()}")
except Exception:
pass
await asyncio.sleep(2)
finally:
adv_stop()
if __name__ == "__main__":
try:
asyncio.run(run())
except KeyboardInterrupt:
print("\nStopped.")
+4 -4
View File
@@ -154,11 +154,11 @@ def detect_circle_v3(frame, laser_point=None):
max_r = max(red_radius, yellow_radius) max_r = max(red_radius, yellow_radius)
size_ratio = min_r / max_r if max_r > 0 else 0 size_ratio = min_r / max_r if max_r > 0 else 0
print(f"Debug -> 圆心距={distance:.1f}(阈值={max_distance:.1f}), " print(f"Debug -> 圆心距={distance:.1f}(阈值={max_distance:.1f}), "
f"大小比={size_ratio:.2f}(阈值=0.5), " f"大小比={size_ratio:.2f}(阈值=0.4), "
f"距离OK={distance < max_distance}, 大小OK={size_ratio > 0.5}") f"距离OK={distance < max_distance}, 大小OK={size_ratio >= 0.4}")
# 允许红圈在黄圈外侧或内侧,只要大小相近(较小/较大 >= 0.5) # 允许红圈在黄圈外侧或内侧,只要大小相近(较小/较大 >= 0.5)
if distance < max_distance and size_ratio > 0.5: if distance < max_distance and size_ratio >= 0.4:
found_valid_red = True found_valid_red = True
print( print(
f"[target] -> 找到匹配的红圈: 黄心({yellow_center}), 红心({red_center}), 距离:{distance:.1f}, 黄半径:{yellow_radius}, 红半径:{red_radius}") f"[target] -> 找到匹配的红圈: 黄心({yellow_center}), 红心({red_center}), 距离:{distance:.1f}, 黄半径:{yellow_radius}, 红半径:{red_radius}")
@@ -598,7 +598,7 @@ if __name__ == "__main__":
# 1. 设置要测试的图片路径 # 1. 设置要测试的图片路径
# 建议将图片放在与脚本同级目录,或者使用绝对路径 # 建议将图片放在与脚本同级目录,或者使用绝对路径
TARGET_IMAGE = "/root/phot/None_314_258_0_0041.bmp" TARGET_IMAGE = "/root/phot/shot_1830921_0_no_target.jpg"
TARGET_DIR = "/root/phot" # 修改为你想要读取的目录路径 TARGET_DIR = "/root/phot" # 修改为你想要读取的目录路径
+108
View File
@@ -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()
+184
View File
@@ -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()
+13 -1
View File
@@ -25,4 +25,16 @@
# 2.15.12 优化算法 # 2.15.12 优化算法
# 2.15.13 优化算法 # 2.15.13 优化算法
# 2.15.14 优化算法 # 2.15.14 优化算法
# 2.15.15 优化wifi连接 # 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
View File
@@ -4,6 +4,6 @@
应用版本号 应用版本号
每次 OTA 更新时,只需要更新这个文件中的版本号 每次 OTA 更新时,只需要更新这个文件中的版本号
""" """
VERSION = '2.15.15' VERSION = '2.18.10'
+66 -8
View File
@@ -631,7 +631,7 @@ def detect_circle_v3(frame, laser_point=None, img_cv=None):
min_r = min(rc["radius"], yellow_radius) min_r = min(rc["radius"], yellow_radius)
max_r = max(rc["radius"], yellow_radius) max_r = max(rc["radius"], yellow_radius)
size_ratio = min_r / max_r if max_r > 0 else 0 size_ratio = min_r / max_r if max_r > 0 else 0
if dist_centers < max_dist and size_ratio > 0.5: 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}, "
@@ -797,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
@@ -908,7 +908,12 @@ def _save_worker_loop():
item = _save_queue.get() item = _save_queue.get()
if item is None: if item is None:
break break
_save_shot_image_impl(*item) 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)
except Exception as e: except Exception as e:
logger = logger_manager.logger logger = logger_manager.logger
if logger: if logger:
@@ -936,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
@@ -965,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)
@@ -976,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
@@ -998,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