54 Commits
Author SHA1 Message Date
linyimin c3f1cfdea0 feat: TCP协议支持protobuf序列化,保留JSON兼容
- 新增 tcp_messages_pb2.py protobuf Python绑定
- msg_handler.cpp: 新增 make_packet_pb/raw bytes打包, parse_packet_raw/raw解析
- archery_netcore.cpp: 注册 make_packet_pb/parse_packet_raw
- network.py: 新增 _use_proto 标志,_make_send_packet/_parse_recv 自动选择proto/JSON
- 登录version加+proto后缀标识proto模式
- protobuf序列化/反序列化失败时自动回退JSON
2026-09-16 09:30:26 +08:00
linyimin 8457bd29c6 fix: 2026-09-04 11:14:17 +08:00
linyimin 5bde549d91 update: version 2026-09-04 11:11:41 +08:00
linyimin d96ca4d031 fix: 调整气压值 2026-09-02 14:19:04 +08:00
linyimin ced66682ed fix: 去除3秒内只能射箭一次的限制 2026-09-02 14:10:46 +08:00
linyimin a09b45738a fix: camera flip 2026-09-02 13:43:16 +08:00
linyimin e4d8454947 fix: update version 2026-09-02 11:26:44 +08:00
linyimin c09189332d fix: 摄像头翻转 2026-09-02 11:25:33 +08:00
yrx a00baa1770 two ! 2026-09-02 11:22:01 +08:00
yrx 179b30a944 two 2026-09-01 11:07:46 +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
linyimin c34efed6f9 fix: wifi连接 2026-06-22 12:05:23 +08:00
linyimin 226394d3ed fix: wifi连接 2026-06-22 12:00:08 +08:00
linyimin b169618b16 fix: 2026-06-16 15:18:38 +08:00
linyimin 5ab4ef2944 fix: 2026-06-10 10:15:11 +08:00
linyimin 577ff02c04 fix:20cm靶的兼容 2026-06-09 18:31:01 +08:00
linyimin 82d0008257 fix: 2026-06-09 11:53:22 +08:00
linyimin 373eeb786a fix: 2026-06-09 10:30:03 +08:00
linyimin 4500e62647 fix: 2026-06-08 17:56:21 +08:00
linyimin 49a84e80e1 fix: 2026-06-08 17:52:53 +08:00
linyimin 9654b79cec fix: 2026-06-08 17:50:31 +08:00
linyimin 1ea8c64a40 feat: conn wifi 2026-06-08 16:46:56 +08:00
linyimin 9dd6fef6f8 fix: 不保存图片 2026-06-08 13:55:37 +08:00
linyimin 860f9c84c3 pref: 20 cm adapter 2026-06-04 15:58:07 +08:00
linyimin 1a0bfd54f7 fix: rm yolo 2026-06-04 09:00:10 +08:00
linyimin c46cf5c567 test: 2026-06-03 18:11:58 +08:00
linyimin 0d69a01a1f pref: 版本说明 2026-06-03 16:02:39 +08:00
linyimin 583748fda3 pref: 版本说明 2026-06-03 14:09:11 +08:00
linyimin d508478c73 fix: 新版本ota 2026-06-03 14:00:28 +08:00
linyimin 30c7200a7a feat: 新版本ota 2026-06-03 13:21:06 +08:00
linyimin 959635f461 feat: 新版本ota 2026-06-03 13:20:46 +08:00
linyimin 86cd8cd46e pref: 2026-06-02 18:24:18 +08:00
linyimin 26ed3c1523 pref: laser find center point 2026-06-02 16:03:18 +08:00
linyimin aa16676c74 pref: laser find center point 2026-06-02 10:32:24 +08:00
linyimin 99614fe321 pref: clean code format 2026-06-02 09:56:59 +08:00
linyimin 2ad2836d77 fix: camera change to camera_manager 2026-06-02 09:55:36 +08:00
linyimin 801453fbdb feat: 根据激光测算中心坐标 2026-06-01 22:42:55 +08:00
63 changed files with 3877 additions and 2967 deletions
-8
View File
@@ -1,8 +0,0 @@
# 默认忽略的文件
/shelf/
/workspace.xml
# 基于编辑器的 HTTP 客户端请求
/httpRequests/
# Datasource local storage ignored files
/dataSources/
/dataSources.local.xml
Generated
-1
View File
@@ -1 +0,0 @@
network.py
-7
View File
@@ -1,7 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<module version="4">
<component name="PyDocumentationSettings">
<option name="format" value="PLAIN" />
<option name="myDocStringFormat" value="Plain" />
</component>
</module>
-6
View File
@@ -1,6 +0,0 @@
<component name="InspectionProjectProfileManager">
<settings>
<option name="USE_PROJECT_PROFILE" value="false" />
<version value="1.0" />
</settings>
</component>
-7
View File
@@ -1,7 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="Black">
<option name="sdkName" value="Python 3.13 virtualenv at H:\iot\racingiot_v1\.venv" />
</component>
<component name="ProjectRootManager" version="2" project-jdk-name="maixcam" project-jdk-type="Python SDK" />
</project>
Generated
-6
View File
@@ -1,6 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="VcsDirectoryMappings">
<mapping directory="" vcs="Git" />
</component>
</project>
+3
View File
@@ -0,0 +1,3 @@
{
"cmake.sourceDirectory": "E:/code/code/code/new/new/new/new/new/nw/archery - 副本/cpp_ext"
}
+88 -24
View File
@@ -109,6 +109,7 @@
from maix import app, uart, pinmap, time
import hashlib
import hmac
import re
import ujson
# ========== 配置 ==========
@@ -130,53 +131,109 @@ def generate_token(device_id):
return "Arrow_" + hmac.new((SALT + device_id).encode(), SALT2.encode(), hashlib.sha256).hexdigest()
def send_cmd(cmd_str, timeout_ms=3000):
"""发送 AT 指令并等待 OK / ERROR"""
"""发送 AT 指令并返回完整响应;超时返回已收到的内容。"""
print("[AT] =>", cmd_str)
http_serial.write((cmd_str + "\r\n").encode())
buffer = b""
start = time.ticks_ms()
while time.ticks_ms() - start < timeout_ms:
while time.ticks_diff(time.ticks_ms(), start) < timeout_ms:
data = http_serial.read(128)
if data:
buffer += data
try:
decoded = buffer.decode()
print("<= ", decoded.strip())
if "OK" in decoded:
return True
if "+CME ERROR" in decoded or "ERROR" in decoded:
return False
decoded = buffer.decode("utf-8", "ignore")
if "OK" in decoded or "+CME ERROR" in decoded or "ERROR" in decoded:
print("[AT] <=", decoded.strip())
return decoded
except:
pass
time.sleep_ms(10)
decoded = buffer.decode("utf-8", "ignore")
print("[AT] !! timeout", timeout_ms, "ms, response:", decoded.strip() or "<empty>")
return decoded
def response_ok(response):
return "OK" in response and "ERROR" not in response
def wait_modem_ready():
"""等待模组响应,并确认 PDP 上下文已经获得 IP。"""
for attempt in range(15):
if response_ok(send_cmd("AT", 1000)):
break
print("[4G] 等待模组启动", attempt + 1, "/15")
time.sleep_ms(1000)
else:
print("[4G] UART2 无 AT 响应,请检查模组供电、A28/A29 接线和串口占用")
return False
send_cmd("ATE0", 1000)
cpin = send_cmd("AT+CPIN?", 3000)
if "READY" not in cpin:
print("[4G] SIM 卡未就绪:", cpin.strip())
return False
addr = send_cmd("AT+CGPADDR=1", 3000)
match = re.search(r'\+CGPADDR:\s*1,"([^\"]+)"', addr)
if match and match.group(1) != "0.0.0.0":
print("[4G] PDP ready, IP:", match.group(1))
return True
send_cmd("AT+MIPCALL=1,1", 15000)
for _ in range(20):
addr = send_cmd("AT+CGPADDR=1", 3000)
match = re.search(r'\+CGPADDR:\s*1,"([^\"]+)"', addr)
if match and match.group(1) != "0.0.0.0":
print("[4G] PDP ready, IP:", match.group(1))
return True
time.sleep_ms(1000)
print("[4G] PDP 未获得 IP,请检查 SIM 流量、信号和 APN")
return False
def clear_http_instances():
for instance_id in range(6):
send_cmd(f"AT+MHTTPDEL={instance_id}", 1200)
def create_http_instance(url):
cmd = f'AT+MHTTPCREATE="{url}"'
if send_cmd(cmd):
# 尝试提取 instance ID(如果模块返回)
# 注意:部分模块不会返回 ID,可忽略,直接用 0 或 1
return True
return False
response = send_cmd(cmd, 8000)
match = re.search(r"\+MHTTPCREATE:\s*(\d+)", response)
if not response_ok(response) or not match:
print("❌ 创建 HTTP 实例失败,模组响应:", response.strip() or "<empty>")
return None
return int(match.group(1))
def send_http_request(url, api_path, token, device_id, json_data):
# 1. 创建 HTTP 实例
if not create_http_instance(url):
print("❌ 创建 HTTP 实例失败")
instance_id = create_http_instance(url)
if instance_id is None:
return False
# 2. 设置 Headers(假设实例 ID 为 0,或根据模块默认)
instance_id = 0 # 大多数模块默认实例为 0;若支持多实例,需解析返回值
send_cmd(f'AT+MHTTPCFG="header",{instance_id},"Content-Type: application/json"')
send_cmd(f'AT+MHTTPCFG="header",{instance_id},"Authorization: {token}"')
send_cmd(f'AT+MHTTPCFG="header",{instance_id},"DeviceId: {device_id}"')
# 2. 设置 Headers
commands = (
f'AT+MHTTPCFG="header",{instance_id},"Content-Type: application/json"',
f'AT+MHTTPCFG="header",{instance_id},"Authorization: {token}"',
f'AT+MHTTPCFG="header",{instance_id},"DeviceId: {device_id}"',
)
for command in commands:
if not response_ok(send_cmd(command)):
print("❌ HTTP Header 配置失败")
send_cmd(f"AT+MHTTPDEL={instance_id}", 2000)
return False
# 3. 发送 Body
json_str = ujson.dumps(json_data)
send_cmd(f'AT+MHTTPCONTENT={instance_id},0,0,"{json_str}"')
at_json = json_str.replace("\\", "\\\\").replace('"', '\\"')
if not response_ok(send_cmd(f'AT+MHTTPCONTENT={instance_id},0,0,"{at_json}"', 8000)):
print("❌ HTTP Body 配置失败")
send_cmd(f"AT+MHTTPDEL={instance_id}", 2000)
return False
# 4. 发起 POST 请求
if send_cmd(f'AT+MHTTPREQUEST={instance_id},2,0,"{api_path}"'):
if response_ok(send_cmd(f'AT+MHTTPREQUEST={instance_id},2,0,"{api_path}"', 15000)):
print("✅ HTTP 请求已发送")
return True
else:
@@ -199,7 +256,7 @@ def read_response(timeout_ms=5000):
print("🚀 启动直接上传流程...")
token = generate_token(device_id)
print("🔑 Token:", token)
print("🔑 Token 已生成:", token[:12] + "...")
# 构造模拟数据
timestamp = int(time.time() * 1000)
@@ -216,7 +273,14 @@ json_data = {
}
# 执行上传
if send_http_request(url, api_path, token, device_id, json_data):
upload_ok = False
if not wait_modem_ready():
print("💥 4G 模组未就绪")
else:
clear_http_instances()
upload_ok = send_http_request(url, api_path, token, device_id, json_data)
if upload_ok:
read_response()
else:
print("💥 上传流程失败")
-1
View File
@@ -1 +0,0 @@
v1.2.15.1] [ERROR] main.py:416 - [MAIN] 显示异常: 'LaserManager' object has no attribute 'remote_detect_tick'
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.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
+6 -6
View File
@@ -4,12 +4,12 @@ from maix import time
a = adc.ADC(0, adc.RES_BIT_12)
while True:
raw_data = a.read()
print(f"ADC raw data:{raw_data}")
if raw_data > 2450:
print(f"ADC raw data:{raw_data}")
elif raw_data < 2000:
print(f"ADC raw data:{raw_data}")
# raw_data = a.read()
# print(f"ADC raw data:{raw_data}")
# if raw_data > 2450:
# print(f"ADC raw data:{raw_data}")
# elif raw_data < 2000:
# print(f"ADC raw data:{raw_data}")
time.sleep_ms(1)
vol = int(a.read_vol() * 10) / 10
+5 -3
View File
@@ -1,6 +1,6 @@
id: t11
name: t11
version: 2.1.1
version: 3.0.5
author: t11
icon: ''
desc: t11
@@ -14,12 +14,14 @@ files:
- cameraParameters.xml
- config.py
- hardware.py
- laser_detector.py
- laser_manager.py
- logger_manager.py
- main.py
- model_270139.cvimodel
- model_270139.mud
- model_285484.cvimodel
- model_285484.mud
- network.py
- ota_curl.sh
- ota_manager.py
- power.py
- server.pem
+7 -6
View File
@@ -76,10 +76,11 @@ class ATClient:
"""
expect_b = expect.encode() if isinstance(expect, str) else expect
with self._cmd_lock:
# 初始化等待
self._waiting = True
self._expect = expect_b
self._resp = b""
with self._q_lock:
# 初始化等待
self._waiting = True
self._expect = expect_b
self._resp = b""
# 发送
if cmd:
@@ -300,8 +301,8 @@ class ATClient:
if len(self._rx) > 512 * 1024:
self._rx = self._rx[-256 * 1024:]
else:
if len(self._rx) > 16384:
self._rx = self._rx[-4096:]
if len(self._rx) > 32768:
self._rx = self._rx[-16384:]
+17 -1
View File
@@ -8,6 +8,15 @@ import threading
import config
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:
"""相机管理器(单例)"""
@@ -57,6 +66,12 @@ class CameraManager:
with self._camera_lock:
if self._camera is None:
self._camera = camera.Camera(width, height)
v_flip = getattr(config, 'CAMERA_V_FLIP', False)
h_mirror = getattr(config, 'CAMERA_H_MIRROR', False)
if v_flip:
self._camera.vflip(1)
if h_mirror:
self._camera.hmirror(1)
return self._camera
@@ -101,7 +116,8 @@ class CameraManager:
with self._camera_lock:
if self._camera is None:
self.init_camera()
return self._camera.read()
frame = self._camera.read()
return frame
def show(self, image):
"""
+48 -27
View File
@@ -15,6 +15,8 @@ LOCAL_FILENAME = APP_DIR + "/main_tmp.py"
# 相机初始化分辨率(CameraManager / main.py 使用)
CAMERA_WIDTH = 640
CAMERA_HEIGHT = 480
CAMERA_V_FLIP = True # 摄像头垂直翻转(上下颠倒时设为 True)
CAMERA_H_MIRROR = True # 摄像头水平镜像(左右反了时设为 True)
# 三角形检测缩图比例:默认按相机最长边缩到 1/2(性能更稳;可按需调整)
# 取值范围建议 (0.25 ~ 1.0]1.0 表示不缩图
@@ -24,7 +26,7 @@ TRIANGLE_DETECT_SCALE = 0.4
# SERVER_IP = "stcp.shelingxingqiu.com"
SERVER_IP = "www.shelingxingqiu.com"
SERVER_PORT = 50005
HEARTBEAT_INTERVAL = 15 # 心跳间隔(秒)
HEARTBEAT_INTERVAL = 5 # 心跳间隔(秒)
# WiFi 质量评估(开机先尝试 WiFi;质量差且 4G 可用则切到 4G,本次上电直至关机锁定 4G)
WIFI_QUALITY_RTT_SAMPLES = 3 # 到业务服务器 TCP 建连耗时采样次数,取中位数
@@ -96,6 +98,11 @@ ADC_LASER_THRESHOLD = 3000
# ==================== 激光配置 ====================
MODULE_ADDR = 0x00
# 激光开关改由 A14 GPIO 控制:低电平开启,高电平关闭。
LASER_CONTROL_PIN = "A14"
LASER_CONTROL_GPIO = "GPIOA14"
LASER_CONTROL_ON_LEVEL = 0
LASER_CONTROL_OFF_LEVEL = 1
LASER_ON_CMD = bytes([0xAA, MODULE_ADDR, 0x01, 0xBE, 0x00, 0x01, 0x00, 0x01, 0xC1])
LASER_OFF_CMD = bytes([0xAA, MODULE_ADDR, 0x01, 0xBE, 0x00, 0x01, 0x00, 0x00, 0xC0])
DISTANCE_QUERY_CMD = bytes([0xAA, MODULE_ADDR, 0x00, 0x20, 0x00, 0x01, 0x00, 0x00, 0x21]) # 激光测距查询命令
@@ -106,14 +113,6 @@ DEFAULT_LASER_POINT = (320, 245) # 默认激光中心点
HARDCODE_LASER_POINT = True # 是否使用硬编码的激光点(True=使用硬编码值,False=使用校准值)
HARDCODE_LASER_POINT_VALUE = (320, 296) # 硬编码的激光点坐标(315, 245) # # 硬编码的激光点坐标 (x, y)
# 远程激光点识别(TCP cmd=200):画面内找红点,稳定 N 秒且无明显跳动后上报坐标
LASER_REMOTE_DETECT_STABLE_SEC = 3.0 # 连续稳定时长(秒)
LASER_REMOTE_DETECT_MAX_MOVE_PX = 12.0 # 窗口内最大位移超过此值视为大幅移动,重新计时
LASER_REMOTE_DETECT_SAMPLE_MS = 80 # 采样间隔
LASER_REMOTE_DETECT_MIN_SAMPLES = 8 # 判定稳定前窗口内最少样本数
LASER_REMOTE_DETECT_WARMUP_MS = 500 # cmd=200 开激光后等待稳定再采样
# 远程识别会话无总超时:cmd=200 启动后持续检测并上报,直至 cmd=201 停止
# 激光点检测配置
LASER_DETECTION_THRESHOLD = 140 # 红色通道阈值(默认120,可调整,范围建议:100-150)
LASER_RED_RATIO = 1.5 # 红色相对于绿色/蓝色的倍数要求(默认1.5,可调整,范围建议:1.3-2.0)
@@ -142,7 +141,7 @@ IMAGE_CENTER_Y = 240 # 图像中心 Y 坐标
# ==================== 三角形四角标记:单应性偏移 + PnP 估距 ====================
# 依赖 cameraParameters.xml(相机内参)与 triangle_positions.json(四角物方坐标,厘米或毫米见 JSON 约定)。
# 部署时请把这两个文件放到 APP_DIR(与 main 同应用目录),或改下面路径为设备上的实际绝对路径。
USE_TRIANGLE_OFFSET = True # False 时仅走黄心圆/椭圆 + 半径估距,不使用三角形路径
USE_TRIANGLE_OFFSET = False # False 时仅走黄心圆/椭圆 + 半径估距,不使用三角形路径
CAMERA_CALIB_XML = APP_DIR + "/cameraParameters.xml"
TRIANGLE_POSITIONS_JSON = APP_DIR + "/triangle_positions.json"
# 检测到的三角形边长在图像中的像素范围,分辨率或靶纸占比变化时可微调
@@ -152,13 +151,6 @@ TRIANGLE_SIZE_RANGE = (8, 500)
# 如果射箭距离很固定,可设具体范围(如 min=2.5, max=6.0)作为额外保险
TRIANGLE_DISTANCE_MIN_M = 0.0 # 0=不启用下限检查
TRIANGLE_DISTANCE_MAX_M = 0.0 # 0=不启用上限检查
# 三角形方向校验:四角黑三角应为 ◤ ◥ / ◣ ◢,即三角形从外角指向靶心;用于过滤相邻靶混入/跨靶组合
TRIANGLE_DIRECTION_VALIDATE_ENABLE = False
TRIANGLE_DIRECTION_MIN_PASS = 3 # 至少多少个真实三角方向正确才认为该组有效;3点补全时推荐3,误检多可设2
TRIANGLE_DIRECTION_DOT_MIN = 0.0 # 方向点积阈值;0=只要求同向半平面,0.35≈夹角<70°,0.5≈夹角<60°
TRIANGLE_DIRECTION_TO_CENTER_DOT_MIN = 0.35 # 必须指向候选靶心;0.35≈夹角<70°,用于过滤相邻靶混入
TRIANGLE_CENTER_DISTANCE_VALIDATE_ENABLE = True # 四角三角到候选靶心距离需近似一致,过滤跨靶组合
TRIANGLE_CENTER_DISTANCE_RATIO_TOL = 0.45 # (max_dist-min_dist)/mean_dist 最大允许值;越小越严格
# 三角形检测兜底增强:CLAHE(更鲁棒但更慢)。颜色阈值修复后通常不需要,保持关闭以优先速度。
TRIANGLE_ENABLE_CLAHE_FALLBACK = False
# 三角形检测调试:保存 Otsu 二值化图像(临时调试用,定位后关闭)
@@ -184,7 +176,6 @@ TRIANGLE_SHAPE_COS_TOLERANCE = 0.25 # 直角余弦绝对值上限(原 0.20
# 建议设为实测最坏耗时的 1.2 倍;超时后圆心检测仍会并行跑完,跑完后若三角形已结束则优先用三角形。
TRIANGLE_TIMEOUT_MS = 1000
# True=打印各阶段耗时(ms),用于定位瓶颈;稳定后可 False 减少日志
ARCHERY_TIMING_ENABLE = False # 总开关:False 关闭所有算法耗时统计(shoot_manager + triangle_target + vision
TRIANGLE_TIMING_LOG = True
# True=Stage2 每个子框内传统三角失败时打一条统计(Otsu/Adaptive 下轮廓数与各拒绝原因计数)
TRIANGLE_LOG_STAGE2_PATCH_REJECT = True
@@ -272,15 +263,21 @@ TRIANGLE_CROP_ROI_MIN_SIDE_PX = 64
# 射箭保存图 / 预览上绘制 YOLO 靶环 ROI 矩形 (x0,y0,x1,y1),核对是否裁准;不需要时改 False
TRIANGLE_YOLO_DRAW_ROI_ON_SHOT = True
# 物方采样调试:以靶心为中心,取半径 15cm 的圆周样本点,用于黑/白颜色对比
TRIANGLE_SAMPLE_ENABLE = True
TRIANGLE_SAMPLE_TIMING_ENABLE = True # 仅统计物方采样耗时(其他 timing 可关)
TRIANGLE_SAMPLE_RADIUS_CM = 15.0
TRIANGLE_SAMPLE_ANGLES_DEG = (0, 90, 180, 270)
TRIANGLE_SAMPLE_PATCH_HALF_PX = 2
# 物方采样判断黑白阈值(R/G/B 均小于此值视为黑);40cm 黑靶在靶面位置全黑,20cm 白靶则 R/G/B 偏高
TRIANGLE_SAMPLE_BLACK_THRESH = 30.0
# 开机阶段预加载 YOLO detectordetect 使用 dual_buff=False,避免返回上一帧结果。
TRIANGLE_YOLO_PRELOAD_ON_BOOT = True
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_285484.mud"
TARGET_CLASS_YOLO_LABELS = (20, 40)
TARGET_CLASS_YOLO_CONF_TH = 0.50
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 训练数据一致)→ 子框内传统算法取直角点 ──
# Stage1 靶环裁切内如何找黑三角标记(对比耗时时可切换):
@@ -328,16 +325,22 @@ LASER_COLOR = (0, 255, 0) # RGB颜色
LASER_THICKNESS = 1
LASER_LENGTH = 2
# ==================== 队列大小限制(防止内存泄漏) ====================
MAX_SEND_QUEUE_SIZE = 500 # 发送队列上限
MAX_TCP_PAYLOADS = 500 # AT TCP 载荷缓存上限
MAX_HTTP_EVENTS = 200 # AT HTTP 事件缓存上限
LOG_QUEUE_MAXSIZE = 10000 # 日志队列上限
MAX_CMD_THREADS = 10 # 并发命令线程上限(防止服务器下发命令时无限创建线程)
# ==================== 图像保存配置 ====================
SAVE_IMAGE_ENABLED = True # 是否保存图像(True=保存,False=不保存)
SAVE_RAW_SHOT_IMAGE_ENABLED = False # 是否额外保存射箭原图;可通过 TCP cmd=46 动态开关
VISION_TIMING_ENABLE = True # 视觉圆检测耗时统计(detect_circle_v3 内部各步骤耗时)
SAVE_IMAGE_ENABLED = False # 是否保存图像(True=保存,False=不保存)
SAVE_IMAGE_ON_FAILURE = True # 检测失败时是否强制保存图像(供调试测试用)
PHOTO_DIR = "/root/phot" # 照片存储目录
MAX_IMAGES = 1000
# Stage2 调试目录(默认 PHOTO_DIR/stage2_roi)内 JPEG 最多保留张数;None 表示与 MAX_IMAGES 相同
TRIANGLE_BLACK_YOLO_STAGE2_ROI_MAX_IMAGES = None
SHOW_CAMERA_PHOTO_WHILE_SHOOTING = True # 是否在拍摄时显示摄像头图像(True=显示,False=不显示),建议在连着USB测试过程中打开
SHOW_CAMERA_PHOTO_WHILE_SHOOTING = False # 是否在拍摄时显示摄像头图像(True=显示,False=不显示),建议在连着USB测试过程中打开
# ==================== OTA配置 ====================
MAX_BACKUPS = 5
@@ -352,11 +355,29 @@ PIN_MAPPINGS = {
"A28": "UART2_TX",
"A15": "I2C5_SCL",
"A27": "I2C5_SDA",
"A14": "GPIOA14", # 激光开关:低开、高关
"A24": "GPIOA24", # 电源板关机控制
"A25": "GPIOA25", # 电源状态绿灯
"A23": "GPIOA23", # 电源状态红灯
}
# ==================== 电源配置 ====================
AUTO_POWER_OFF_IN_SECONDS = 10 * 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_AVG_WINDOW = 5
+7
View File
@@ -57,9 +57,16 @@ PYBIND11_MODULE(archery_netcore, m) {
"Pack TCP packet: header (len+type+checksum) + JSON body",
py::arg("msg_type"), py::arg("body_dict"));
m.def("make_packet_pb", &netcore::make_packet_pb,
"Pack TCP packet: header (len+type+checksum) + raw bytes body (for protobuf)",
py::arg("msg_type"), py::arg("body_bytes"));
m.def("parse_packet", &netcore::parse_packet,
"Parse TCP packet, return (msg_type, body_dict)");
m.def("parse_packet_raw", &netcore::parse_packet_raw,
"Parse TCP packet, return (msg_type, body_bytes) without JSON parsing");
m.def("get_config", &get_config, "Get system configuration");
m.def(
+59
View File
@@ -51,6 +51,43 @@ namespace netcore {
return py::bytes(reinterpret_cast<const char*>(packet.data()), packet.size());
}
// 打包 TCP 数据包 (raw bytes body, 用于 protobuf)
py::bytes make_packet_pb(int msg_type, py::bytes body_bytes) {
netcore::log_debug(std::string("make_packet_pb msg_type=") + std::to_string(msg_type));
py::buffer_info buf = py::buffer(body_bytes).request();
uint32_t body_len = buf.size;
uint32_t checksum = body_len + msg_type;
std::vector<uint8_t> packet;
packet.reserve(12 + body_len);
// body_len (big-endian, 4 bytes)
packet.push_back((body_len >> 24) & 0xFF);
packet.push_back((body_len >> 16) & 0xFF);
packet.push_back((body_len >> 8) & 0xFF);
packet.push_back(body_len & 0xFF);
// msg_type (big-endian, 4 bytes)
packet.push_back((msg_type >> 24) & 0xFF);
packet.push_back((msg_type >> 16) & 0xFF);
packet.push_back((msg_type >> 8) & 0xFF);
packet.push_back(msg_type & 0xFF);
// checksum (big-endian, 4 bytes)
packet.push_back((checksum >> 24) & 0xFF);
packet.push_back((checksum >> 16) & 0xFF);
packet.push_back((checksum >> 8) & 0xFF);
packet.push_back(checksum & 0xFF);
// 追加 body bytes
const uint8_t* body_ptr = static_cast<const uint8_t*>(buf.ptr);
packet.insert(packet.end(), body_ptr, body_ptr + body_len);
netcore::log_debug(std::string("make_packet_pb done bytes=") + std::to_string(packet.size()));
return py::bytes(reinterpret_cast<const char*>(packet.data()), packet.size());
}
// 解析 TCP 数据包
py::tuple parse_packet(py::bytes data) {
// 1) 转换为 bytes view
@@ -110,4 +147,26 @@ namespace netcore {
return py::make_tuple(py::int_(msg_type), raw_dict);
}
}
// 解析 TCP 数据包 -> (msg_type, body_bytes) 不做 JSON 解析
py::tuple parse_packet_raw(py::bytes data) {
py::buffer_info buf = py::buffer(data).request();
if (buf.size < 12) {
return py::make_tuple(py::none(), py::none());
}
const uint8_t* ptr = static_cast<const uint8_t*>(buf.ptr);
uint32_t body_len = (ptr[0] << 24) | (ptr[1] << 16) | (ptr[2] << 8) | ptr[3];
uint32_t msg_type = (ptr[4] << 24) | (ptr[5] << 16) | (ptr[6] << 8) | ptr[7];
uint32_t expected_len = 12 + body_len;
if (buf.size < expected_len) {
return py::make_tuple(py::none(), py::none());
}
// 返回原始 body bytes(不做 JSON 解析)
py::bytes body_bytes(reinterpret_cast<const char*>(ptr + 12), body_len);
return py::make_tuple(py::int_(msg_type), body_bytes);
}
}
+9 -2
View File
@@ -7,8 +7,15 @@ namespace py = pybind11;
namespace netcore {
// 打包 TCP 数据包
// 打包 TCP 数据包 (JSON body)
py::bytes make_packet(int msg_type, py::dict body_dict);
// 解包 TCP 数据包
// 打包 TCP 数据包 (raw bytes body, 用于 protobuf)
py::bytes make_packet_pb(int msg_type, py::bytes body_bytes);
// 解包 TCP 数据包 -> (msg_type, body_dict)
py::tuple parse_packet(py::bytes data);
// 解包 TCP 数据包 -> (msg_type, body_bytes) 不做 JSON 解析
py::tuple parse_packet_raw(py::bytes data);
}
+79 -1
View File
@@ -5,6 +5,7 @@
提供硬件对象的统一管理和访问
"""
from maix import time
import _thread
import config
from at_client import ATClient
@@ -28,6 +29,7 @@ class HardwareManager:
self._bus = None # I2C总线
self._adc_obj = None # ADC对象
self._at_client = None # AT客户端
self._status_led_monitor_started = False
self._last_active_time = 0 # 用于记录用户的最后一次活跃的时间
self._stop_timer = False # 用于停止定时器的标志
@@ -104,11 +106,87 @@ class HardwareManager:
# 物理引脚是 A24,对应 GPIO 功能是 GPIOA24
# 注意:这里需要先在 config.PIN_MAPPINGS 中配置好 "A24": "GPIOA24"
from maix import gpio
# 输出高电平关闭
# 一代电源板关机信号为高电平
gpio.GPIO("GPIOA24", gpio.Mode.OUT).value(1)
except Exception as e:
print(f"关机失败: {e}")
def start_status_led_monitor(self):
"""后台更新状态灯:正常/充满绿常亮、充电绿闪烁、低电量红常亮。"""
if self._status_led_monitor_started:
return
self._status_led_monitor_started = True
_thread.start_new_thread(self._status_led_loop, ())
def _status_led_loop(self):
from maix import gpio
from power import get_bus_voltage, is_charging, voltage_to_percent
try:
green = None
if getattr(config, "STATUS_LED_GREEN_ENABLED", True):
green = gpio.GPIO(config.STATUS_LED_GREEN_GPIO, gpio.Mode.OUT)
red = None
if getattr(config, "STATUS_LED_RED_ENABLED", True):
red = gpio.GPIO(config.STATUS_LED_RED_GPIO, gpio.Mode.OUT)
active = int(config.STATUS_LED_ACTIVE_LEVEL)
inactive = 0 if active else 1
if green is not None:
green.value(inactive)
if red is not None:
red.value(inactive)
last_state = None
blink_on = False
blink_period = max(100, int(config.STATUS_LED_CHARGING_BLINK_MS))
poll_ms = max(100, int(config.STATUS_LED_POLL_MS))
tick_ms = min(blink_period, poll_ms)
sensor_elapsed = poll_ms
blink_elapsed = blink_period
state = "normal"
percent = None
charging = False
while self._status_led_monitor_started:
if sensor_elapsed >= poll_ms:
voltage = get_bus_voltage()
percent = voltage_to_percent(voltage) if voltage > 0 else None
charging = is_charging()
low = percent is not None and percent <= int(config.STATUS_LED_LOW_BATTERY_PERCENT)
full = percent is not None and percent >= int(config.STATUS_LED_FULL_BATTERY_PERCENT)
if charging:
state = "full" if full else "charging"
else:
state = "low" if low else "normal"
sensor_elapsed = 0
if state == "low":
if green is not None:
green.value(inactive)
if red is not None:
red.value(active)
elif state == "charging":
if blink_elapsed >= blink_period:
blink_on = not blink_on
blink_elapsed = 0
if green is not None:
green.value(active if blink_on else inactive)
if red is not None:
red.value(inactive)
else: # normal or full
if green is not None:
green.value(active)
if red is not None:
red.value(inactive)
if state != last_state:
print(f"[STATUS_LED] state={state} percent={percent} charging={charging}")
last_state = state
time.sleep_ms(tick_ms)
sensor_elapsed += tick_ms
blink_elapsed += tick_ms
except Exception as e:
self._status_led_monitor_started = False
print(f"[STATUS_LED] monitor failed: {e}")
def start_idle_timer(self):
self._stop_timer = False
self._last_active_time = time.time()
+248
View File
@@ -0,0 +1,248 @@
from maix import image, time
from logger_manager import logger_manager
from camera_manager import camera_manager
_USE_CV = False
try:
import cv2
import numpy as np
_USE_CV = True
except ImportError:
pass
WIDTH = 640
HEIGHT = 480
THRESHOLD = 100
RED_RATIO = 1.5
SEARCH_RADIUS = 80
TRACK_RADIUS = 30
MIN_PIXELS = 3
COARSE_STEP = 2
STABLE_COUNT = 2
MAX_SKIP_FRAMES = 5
# Temporal smoothing
_EMA_ALPHA = 0.35
_GATE_PX = 10
_FRAME_INTERVAL_MS = 50
_prev_smoothed = None
def _red_weighted_centroid(r_ch, g_ch, b_ch, mask, x0, y0):
y_ids, x_ids = np.where(mask)
if len(y_ids) == 0:
return None
r_vals = r_ch[y_ids, x_ids].astype(np.float64)
g_vals = g_ch[y_ids, x_ids].astype(np.float64)
b_vals = b_ch[y_ids, x_ids].astype(np.float64)
w = r_vals - np.maximum(g_vals, b_vals)
w = np.clip(w, 0, None)
w = w * w
total_w = w.sum()
if total_w < 1e-6:
return None
cx = (x_ids.astype(np.float64) * w).sum() / total_w + x0
cy = (y_ids.astype(np.float64) * w).sum() / total_w + y0
return (float(cx), float(cy))
def find_ellipse(img_cv, cx, cy, roi_r, th, ratio):
x1 = max(0, cx - roi_r)
x2 = min(WIDTH, cx + roi_r)
y1 = max(0, cy - roi_r)
y2 = min(HEIGHT, cy + roi_r)
roi = img_cv[y1:y2, x1:x2]
if roi.size == 0:
return None
r = roi[:, :, 0].astype(np.int32)
g = roi[:, :, 1].astype(np.int32)
b = roi[:, :, 2].astype(np.int32)
mask = (r > th) & (r > g * ratio) & (r > b * ratio)
oe = (r > 200) & (g > 200) & (b > 200) & (r >= g) & (r >= b) & ((r - g) > 10) & ((r - b) > 10)
combined = (mask | oe).astype(np.uint8) * 255
contours, _ = cv2.findContours(combined, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE)
if not contours:
return None
largest = max(contours, key=cv2.contourArea)
if cv2.contourArea(largest) < 5:
return None
cnt = largest.copy()
for pt in cnt:
pt[0][0] += x1
pt[0][1] += y1
ellipse_valid = len(cnt) >= 5
if ellipse_valid:
(ex, ey), (ew, eh), ang = cv2.fitEllipse(cnt)
mask_ellipse = np.zeros((HEIGHT, WIDTH), dtype=np.uint8)
cv2.ellipse(mask_ellipse, (int(ex), int(ey)), (int(ew / 2), int(eh / 2)), ang, 0, 360, 255, -1)
return _red_weighted_centroid(
img_cv[:, :, 0], img_cv[:, :, 1], img_cv[:, :, 2],
mask_ellipse > 0, 0, 0
)
M = cv2.moments(cnt)
if M["m00"] > 0:
return (float(M["m10"] / M["m00"]), float(M["m01"] / M["m00"]))
return None
def is_red(r, g, b, th, ratio):
if r > th and r > g * ratio and r > b * ratio:
return True
if (r > 200 and g > 200 and b > 200 and r >= g and r >= b
and (r - g) > 10 and (r - b) > 10):
return True
return False
def find_brightest_bytes(frame, cx, cy, roi_r, th, ratio):
x1 = max(0, cx - roi_r)
x2 = min(WIDTH, cx + roi_r)
y1 = max(0, cy - roi_r)
y2 = min(HEIGHT, cy + roi_r)
data = frame.to_bytes()
best_score = 0
best_x = (x1 + x2) // 2
best_y = (y1 + y2) // 2
found_any = False
for y in range(y1, y2, COARSE_STEP):
for x in range(x1, x2, COARSE_STEP):
idx = (y * WIDTH + x) * 3
r = data[idx]
g = data[idx + 1]
b = data[idx + 2]
if is_red(r, g, b, th, ratio):
score = r + g + b
dx = x - cx
dy = y - cy
dist_decay = max(0.5, 1.0 - ((dx * dx + dy * dy) ** 0.5 / roi_r) * 0.5)
score *= dist_decay
if score > best_score:
best_score = score
best_x = x
best_y = y
found_any = True
if not found_any:
return None
sf = 4
fx1 = max(x1, best_x - sf)
fx2 = min(x2, best_x + sf + 1)
fy1 = max(y1, best_y - sf)
fy2 = min(y2, best_y + sf + 1)
sum_x = 0.0
sum_y = 0.0
total_w = 0.0
count = 0
for y in range(fy1, fy2):
for x in range(fx1, fx2):
idx = (y * WIDTH + x) * 3
r = data[idx]
g = data[idx + 1]
b = data[idx + 2]
if is_red(r, g, b, th, ratio):
w = r + g + b
sum_x += x * w
sum_y += y * w
total_w += w
count += 1
if count < MIN_PIXELS:
return (float(best_x), float(best_y))
return (float(sum_x / total_w), float(sum_y / total_w))
def _ema_filter(pos, alpha=_EMA_ALPHA):
global _prev_smoothed
if _prev_smoothed is None:
_prev_smoothed = pos
return pos
sx = alpha * pos[0] + (1 - alpha) * _prev_smoothed[0]
sy = alpha * pos[1] + (1 - alpha) * _prev_smoothed[1]
_prev_smoothed = (sx, sy)
return _prev_smoothed
def _gated(pos, gate_px=_GATE_PX):
global _prev_smoothed
if _prev_smoothed is None:
return True
dx = pos[0] - _prev_smoothed[0]
dy = pos[1] - _prev_smoothed[1]
return (dx * dx + dy * dy) <= gate_px * gate_px
def get_stable_laser_point(timeout_ms=15000, stable_count=STABLE_COUNT):
global _prev_smoothed
_prev_smoothed = None
try:
last_raw = None
stable = 0
start = time.ticks_ms()
cx, cy = WIDTH // 2, HEIGHT // 2
track_count = 0
skip_count = 0
while True:
if abs(time.ticks_diff(time.ticks_ms(), start)) > timeout_ms:
_prev_smoothed = None
return None
frame = camera_manager.read_frame()
if frame is None:
time.sleep_ms(10)
continue
if track_count > 0 and _prev_smoothed is not None:
search_cx = int(_prev_smoothed[0])
search_cy = int(_prev_smoothed[1])
search_r = TRACK_RADIUS
else:
search_cx = cx
search_cy = cy
search_r = SEARCH_RADIUS
pos_bright = find_brightest_bytes(frame, search_cx, search_cy, search_r, THRESHOLD, RED_RATIO)
pos = pos_bright
if _USE_CV:
img_cv = image.image2cv(frame, False, False)
pos_ellipse = find_ellipse(img_cv, search_cx, search_cy, search_r, THRESHOLD, RED_RATIO)
if pos_ellipse is not None:
pos = pos_ellipse
if pos is not None:
skip_count = 0
track_count += 1
filtered = _ema_filter(pos)
if last_raw is not None:
dx = abs(filtered[0] - last_raw[0])
dy = abs(filtered[1] - last_raw[1])
if dx <= 2 and dy <= 2:
stable += 1
else:
stable = 1
else:
stable = 1
last_raw = filtered
if logger_manager.logger:
logger_manager.logger.info(f"pos:{pos},filtered:{filtered},stable:{stable}")
if stable >= stable_count:
result = (int(filtered[0]), int(filtered[1]))
_prev_smoothed = None
return result
else:
skip_count += 1
if logger_manager.logger:
logger_manager.logger.info(f"find_brightest_bytes None, skip={skip_count}, track={track_count}, search_center=({search_cx},{search_cy}), search_r={search_r}")
if skip_count > MAX_SKIP_FRAMES:
_prev_smoothed = None
track_count = 0
stable = 0
last_raw = None
time.sleep_ms(_FRAME_INTERVAL_MS)
finally:
_prev_smoothed = None
+83 -492
View File
@@ -6,7 +6,6 @@
"""
import _thread
import json
import math
import os
import binascii
from maix import time
@@ -32,16 +31,13 @@ class LaserManager:
# 私有状态
self._serial = None # 激光串口,由 laser_manager 自己持有
self._laser_gpio = None # A14 激光开关,低电平开启、高电平关闭
self._calibration_active = False
self._calibration_result = None
self._calibration_lock = threading.Lock()
self._remote_detect_active = False
self._remote_detect_lock = threading.Lock()
self._remote_detect_result = None
self._laser_point = None
self._laser_turned_on = False
self._last_frame_with_ellipse = None # 保存绘制了椭圆的图像(用于调试/显示)
self._remote_detect_last_pos = None
self._initialized = True
# ==================== 状态访问(只读属性)====================
@@ -59,8 +55,8 @@ class LaserManager:
@property
def laser_point(self):
"""当前激光点(如果启用硬编码,则返回硬编码值)"""
if config.HARDCODE_LASER_POINT:
return config.HARDCODE_LASER_POINT_VALUE
# if config.HARDCODE_LASER_POINT:
# return config.HARDCODE_LASER_POINT_VALUE
return self._laser_point
def get_last_frame_with_ellipse(self):
@@ -74,10 +70,21 @@ class LaserManager:
# ==================== 初始化方法 ====================
def init_control_gpio(self):
"""尽早初始化 A14,并拉高确保激光关闭。"""
from maix import gpio, pinmap
pinmap.set_pin_function(config.LASER_CONTROL_PIN, config.LASER_CONTROL_GPIO)
if self._laser_gpio is None:
self._laser_gpio = gpio.GPIO(config.LASER_CONTROL_GPIO, gpio.Mode.OUT)
self._laser_gpio.value(config.LASER_CONTROL_OFF_LEVEL)
self._laser_turned_on = False
print(f"[LASER] {config.LASER_CONTROL_PIN}=HIGH,激光已关闭")
def init(self, serial_device=None, baudrate=None):
"""
初始化激光模块(包括串口)
初始化完成后主动发送关闭命令,防止 UART 初始化噪声误触发激光
初始化激光模块(A14 开关 + 测距串口)
初始化时先将 A14 拉高关闭激光,防止开机误触发
Args:
serial_device: 串口设备路径,默认使用 config.DISTANCE_SERIAL_DEVICE
@@ -87,282 +94,38 @@ class LaserManager:
device = serial_device or config.DISTANCE_SERIAL_DEVICE
baud = baudrate or config.DISTANCE_SERIAL_BAUDRATE
self.init_control_gpio()
self._serial = uart.UART(device, baud)
print(f"[LASER] 激光串口初始化完成: device={device}, baudrate={baud}")
# 等待串口稳定后主动关闭激光,防止初始化噪声误触发
time.sleep_ms(100)
try:
self._serial.read(-1) # 清空接收缓冲区
except Exception:
pass
self._serial.write(config.LASER_OFF_CMD)
time.sleep_ms(60)
try:
self._serial.read(-1) # 清空回包
except Exception:
pass
print("[LASER] 已发送关闭命令(防止开机误触发)")
# ==================== 业务方法 ====================
def load_laser_point(self):
"""从配置文件加载激光中心点,失败则使用默认值
如果启用硬编码模式,则直接使用硬编码值
"""
if config.HARDCODE_LASER_POINT:
# 硬编码模式:直接使用硬编码值
self._laser_point = config.HARDCODE_LASER_POINT_VALUE
self.logger.info(f"[LASER] 使用硬编码激光点: {self._laser_point}")
return self._laser_point
# 正常模式:从配置文件加载
"""加载激光中心点:优先使用本地保存的坐标,其次硬编码值,最后默认值"""
# 优先:从本地持久化文件加载(由 cmd 201 保存)
try:
if os.path.exists(config.CONFIG_FILE):
if "laser_config.json" in os.listdir("/root"):
with open(config.CONFIG_FILE, "r") as f:
data = json.load(f)
if isinstance(data, list) and len(data) == 2:
self._laser_point = (int(data[0]), int(data[1]))
self.logger.debug(f"[INFO] 加载激光点: {self._laser_point}")
self.logger.info(f"[LASER] 从本地加载激光点: {self._laser_point}")
return self._laser_point
else:
raise ValueError
else:
self._laser_point = config.DEFAULT_LASER_POINT
except Exception as e:
if self.logger:
self.logger.warning(f"[LASER] 加载激光点失败,使用默认值: {e}")
self._laser_point = config.DEFAULT_LASER_POINT
except Exception:
pass
# 其次:硬编码值
if config.HARDCODE_LASER_POINT:
self._laser_point = config.HARDCODE_LASER_POINT_VALUE
self.logger.info(f"[LASER] 使用硬编码激光点: {self._laser_point}")
return self._laser_point
# 最后:默认值
self._laser_point = config.DEFAULT_LASER_POINT
self.logger.info(f"[LASER] 使用默认激光点: {self._laser_point}")
return self._laser_point
@property
def remote_detect_active(self):
with self._remote_detect_lock:
return self._remote_detect_active
def get_remote_detect_result(self):
"""获取并清除远程激光识别结果 (x, y) 或 None。"""
with self._remote_detect_lock:
result = self._remote_detect_result
self._remote_detect_result = None
return result
def remote_detect_tick(self, frame):
"""
主循环显示路径调用的轻量 tick。
兼容旧调用点:当前远程识别由后台线程处理,这里不做重计算,
仅保留接口避免 AttributeError。
"""
return None
def overlay_remote_detect_preview(self, frame):
"""
在预览画面叠加远程识别点与坐标文本。
"""
try:
import cv2
from maix import image
with self._remote_detect_lock:
pos = self._remote_detect_last_pos
if not pos:
return frame
img_cv = image.image2cv(frame, False, False)
if img_cv is None or img_cv.size == 0:
return frame
x, y = int(pos[0]), int(pos[1])
h, w = img_cv.shape[:2]
if x < 0 or y < 0 or x >= w or y >= h:
return frame
color = (255, 0, 0) # RGB
cv2.circle(img_cv, (x, y), 8, color, 2)
cv2.line(img_cv, (x - 12, y), (x + 12, y), color, 1)
cv2.line(img_cv, (x, y - 12), (x, y + 12), color, 1)
cv2.putText(img_cv, f"laser=({x},{y})", (max(5, x + 10), max(20, y - 10)),
cv2.FONT_HERSHEY_SIMPLEX, 0.55, color, 1, cv2.LINE_AA)
return image.cv2image(img_cv, False, False)
except Exception as e:
if self.logger:
self.logger.debug(f"[LASER-REMOTE] overlay 绘制失败: {e}")
return frame
def _set_remote_detect_result(self, result):
with self._remote_detect_lock:
self._remote_detect_result = result
def set_hardcoded_laser_point(self, x, y):
"""更新 config.HARDCODE_LASER_POINT_VALUETCP cmd=201)。"""
try:
ix = int(round(float(x)))
iy = int(round(float(y)))
except (TypeError, ValueError) as e:
raise ValueError(f"invalid laser point ({x!r}, {y!r})") from e
config.HARDCODE_LASER_POINT = True
config.HARDCODE_LASER_POINT_VALUE = (ix, iy)
self._laser_point = (ix, iy)
try:
with open(config.CONFIG_FILE, "w") as f:
json.dump([ix, iy], f)
except Exception as e:
if self.logger:
self.logger.warning(f"[LASER] 保存硬编码激光点到本地失败: {e}")
raise
if self.logger:
self.logger.info(
f"[LASER] 已设置硬编码激光点 HARDCODE_LASER_POINT_VALUE=({ix}, {iy}) 并已保存到 {config.CONFIG_FILE}"
)
return ix, iy
def start_remote_laser_detect(self):
"""
启动远程激光识别会话(TCP cmd=200):开激光后持续检测。
每次稳定 3s 上报一次坐标,外循环直到 cmd=201 调用 stop_remote_laser_detect()。
Returns:
True 已启动;False 会话已在运行
"""
with self._remote_detect_lock:
if self._remote_detect_active:
return False
self._remote_detect_active = True
self._remote_detect_result = None
self._remote_detect_last_pos = None
_thread.start_new_thread(self._remote_laser_detect_worker, ())
if self.logger:
self.logger.info("[LASER] 远程激光识别已启动 (cmd=200)")
return True
def stop_remote_laser_detect(self):
with self._remote_detect_lock:
self._remote_detect_active = False
def _remote_laser_detect_worker(self):
from camera_manager import camera_manager
stable_sec = float(getattr(config, "LASER_REMOTE_DETECT_STABLE_SEC", 3.0))
max_move = float(getattr(config, "LASER_REMOTE_DETECT_MAX_MOVE_PX", 12.0))
sample_ms = int(getattr(config, "LASER_REMOTE_DETECT_SAMPLE_MS", 80))
min_samples = int(getattr(config, "LASER_REMOTE_DETECT_MIN_SAMPLES", 8))
warmup_ms = int(getattr(config, "LASER_REMOTE_DETECT_WARMUP_MS", 500))
stable_ms = int(max(500, stable_sec * 1000))
samples = []
miss_count = 0
stable_hit_count = 0
reported = False
try:
if not self._laser_turned_on:
try:
self.turn_on_laser()
except Exception as e:
if self.logger:
self.logger.warning(f"[LASER] cmd200 worker 开激光失败: {e}")
if warmup_ms > 0:
if self.logger:
self.logger.info(f"[LASER] cmd200 激光预热 {warmup_ms}ms …")
time.sleep_ms(warmup_ms)
if self.logger:
self.logger.info("[LASER] 远程识别外循环已启动,直至 cmd=201 停止")
while True:
with self._remote_detect_lock:
if not self._remote_detect_active:
if self.logger:
self.logger.info("[LASER] 远程识别会话结束 (cmd=201 或取消)")
return
try:
frame = camera_manager.read_frame()
pos = self.find_red_laser_remote(frame)
except Exception as e:
if self.logger:
self.logger.warning(f"[LASER] 远程识别帧异常: {e}")
pos = None
time.sleep_ms(sample_ms)
continue
now_ms = time.ticks_ms()
if pos is None:
miss_count += 1
samples.clear()
stable_hit_count = 0
if miss_count == 1 or miss_count % 40 == 0:
if self.logger:
self.logger.info(
f"[LASER-REMOTE] 本帧未检出激光点(累计 {miss_count} 帧),"
f"全图多策略搜索中…"
)
time.sleep_ms(sample_ms)
continue
miss_count = 0
x, y = float(pos[0]), float(pos[1])
samples.append((now_ms, x, y))
cutoff = now_ms - stable_ms
samples = [(t, px, py) for t, px, py in samples if t >= cutoff]
if len(samples) < 2:
time.sleep_ms(sample_ms)
continue
xs = [s[1] for s in samples]
ys = [s[2] for s in samples]
span = max(
max(xs) - min(xs),
max(ys) - min(ys),
)
for i in range(len(samples)):
for j in range(i + 1, len(samples)):
d = math.hypot(
samples[i][1] - samples[j][1],
samples[i][2] - samples[j][2],
)
span = max(span, d)
if span > max_move:
if self.logger:
self.logger.debug(
f"[LASER] 检测到大幅位移 span={span:.1f}px>{max_move},重新计时"
)
samples.clear()
stable_hit_count = 0
time.sleep_ms(sample_ms)
continue
window_ms = samples[-1][0] - samples[0][0]
if window_ms >= stable_ms and len(samples) >= min_samples:
fx = int(round(sum(xs) / len(xs)))
fy = int(round(sum(ys) / len(ys)))
stable_hit_count += 1
if self.logger:
self.logger.info(
f"[LASER] 远程识别稳定命中 {stable_hit_count}/3 span={span:.1f}px → ({fx}, {fy})"
)
samples.clear()
if stable_hit_count >= 3 and not reported:
reported = True
self._set_remote_detect_result(
{"result":"laser_detect_ok", "x": fx, "y": fy}
)
if self.logger:
self.logger.info(
f"[LASER] 已连续3次坐标稳定,完成上报,继续等待 cmd=201 关闭会话"
)
time.sleep_ms(sample_ms)
continue
time.sleep_ms(sample_ms)
finally:
if self.logger:
self.logger.info("[LASER] 远程识别线程退出,等待下一次 cmd=200")
with self._remote_detect_lock:
self._remote_detect_active = False
def save_laser_point(self, point):
"""保存激光中心点到配置文件
如果启用硬编码模式,则不保存(直接返回 True)
@@ -384,66 +147,38 @@ class LaserManager:
return False
def turn_on_laser(self):
"""发送指令开启激光,并读取回包(部分模块支持)"""
if self._serial is None:
self.logger.error("[LASER] 激光串口未初始化,请先调用 init()")
return None
# 打印调试信息
self.logger.info(f"[LASER] 发送开启命令: {config.LASER_ON_CMD.hex()}")
# 清空接收缓冲区
"""A14 输出低电平,开启激光。"""
if self._laser_gpio is None:
if self.logger:
self.logger.error("[LASER] A14 GPIO 未初始化,请先调用 init()")
return False
try:
self._serial.read(-1) # 清空缓冲区
except:
pass
# 发送命令
written = self._serial.write(config.LASER_ON_CMD)
self.logger.info(f"[LASER] 写入字节数: {written}")
time.sleep_ms(60)
# 读取回包
resp = self._serial.read(len=20, timeout=10)
if resp:
self.logger.info(f"[LASER] 收到回包 ({len(resp)}字节): {resp.hex()}")
if resp == config.LASER_ON_CMD:
self.logger.info("✅ 激光开启指令已确认")
else:
self.logger.warning("🔇 无回包(可能正常或模块不支持回包)")
self._laser_turned_on = True
return resp
self._laser_gpio.value(config.LASER_CONTROL_ON_LEVEL)
self._laser_turned_on = True
if self.logger:
self.logger.info("[LASER] A14=LOW,激光开启")
return True
except Exception as e:
if self.logger:
self.logger.error(f"[LASER] A14 开启激光失败: {e}")
return False
def turn_off_laser(self):
"""发送指令关闭激光"""
if self._serial is None:
self.logger.error("[LASER] 激光串口未初始化,请先调用 init()")
return None
# 打印调试信息
self.logger.info(f"[LASER] 发送关闭命令: {config.LASER_OFF_CMD.hex()}")
# 清空接收缓冲区
"""A14 输出高电平,关闭激光"""
if self._laser_gpio is None:
if self.logger:
self.logger.error("[LASER] A14 GPIO 未初始化,请先调用 init()")
return False
try:
self._serial.read(-1)
except:
pass
# 发送命令
written = self._serial.write(config.LASER_OFF_CMD)
self.logger.info(f"[LASER] 写入字节数: {written}")
time.sleep_ms(60)
# 读取回包
resp = self._serial.read(20)
if resp:
self.logger.info(f"[LASER] 收到回包 ({len(resp)}字节): {resp.hex()}")
else:
self.logger.warning("🔇 无回包")
self._laser_turned_on = False
return resp
self._laser_gpio.value(config.LASER_CONTROL_OFF_LEVEL)
self._laser_turned_on = False
if self.logger:
self.logger.info("[LASER] A14=HIGH,激光关闭")
return True
except Exception as e:
if self.logger:
self.logger.error(f"[LASER] A14 关闭激光失败: {e}")
return False
def flash_laser(self, duration_ms=1000):
"""闪一下激光(非阻塞版本)"""
@@ -1065,172 +800,6 @@ class LaserManager:
# 使用原来的最亮点方法
return self._find_red_laser_brightest(frame, threshold, search_radius, ellipse_params)
def find_red_laser_remote(self, frame):
"""
cmd=200 远程识别专用:全图搜索、多策略、放宽阈值,不限距画面中心距离。
常规 find_red_laser 仅搜中心 ±LASER_SEARCH_RADIUS 且距中心 >50px 会丢弃。
"""
import cv2
import numpy as np
from maix import image
img_cv = image.image2cv(frame, False, False)
if img_cv is None or img_cv.size == 0:
return None
h, w = img_cv.shape[:2]
r = img_cv[:, :, 0].astype(np.int32)
g = img_cv[:, :, 1].astype(np.int32)
b = img_cv[:, :, 2].astype(np.int32)
brightness = r + g + b
red_ratio = float(getattr(config, "LASER_RED_RATIO", 1.5))
ratio_lo = max(1.15, red_ratio - 0.35)
strategies = []
base_th = int(getattr(config, "LASER_DETECTION_THRESHOLD", 140))
for th in (base_th, 120, 100, 80, 60):
mask = (
(r > th)
& (r > g * ratio_lo)
& (r > b * ratio_lo)
)
strategies.append(("rgb", th, mask))
oe_th = int(getattr(config, "LASER_OVEREXPOSED_THRESHOLD", 200))
oe_diff = int(getattr(config, "LASER_OVEREXPOSED_DIFF", 10))
mask_oe = (
(r > oe_th - 30)
& (g > oe_th - 40)
& (b > oe_th - 40)
& (r >= g)
& (r >= b)
& ((r - g) > max(5, oe_diff - 5))
& ((r - b) > max(5, oe_diff - 5))
)
strategies.append(("overexposed", oe_th, mask_oe))
mask_bright = (brightness > 380) & (r >= g) & (r >= b) & ((r - g) > 3)
strategies.append(("bright", 0, mask_bright))
hsv = cv2.cvtColor(img_cv, cv2.COLOR_RGB2HSV)
hc, sc, vc = cv2.split(hsv)
mask_hsv = ((hc <= 18) | (hc >= 162)) & (sc >= 60) & (vc >= 60)
strategies.append(("hsv", 0, mask_hsv))
best_pos = None
best_score = -1.0
best_tag = None
max_area = float(getattr(config, "LASER_REMOTE_MAX_AREA", 300.0))
min_circularity = float(getattr(config, "LASER_REMOTE_MIN_CIRCULARITY", 0.25))
for name, th, mask in strategies:
m = (mask.astype(np.uint8)) * 255
if cv2.countNonZero(m) == 0:
continue
contours, _ = cv2.findContours(m, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE)
if not contours:
continue
for cnt in contours:
area = cv2.contourArea(cnt)
if area < 1.5 or area > max_area:
continue
peri = cv2.arcLength(cnt, True)
if peri <= 0:
continue
circularity = float(4.0 * math.pi * area / (peri * peri))
if circularity < min_circularity:
continue
M = cv2.moments(cnt)
if M["m00"] <= 0:
continue
cx = float(M["m10"] / M["m00"])
cy = float(M["m01"] / M["m00"])
ix, iy = int(round(cx)), int(round(cy))
if ix < 0 or iy < 0 or ix >= w or iy >= h:
continue
local_r = float(r[iy, ix])
score = area * local_r * (1.0 + local_r / 255.0) * (0.5 + circularity)
if score > best_score:
best_score = score
best_pos = (ix, iy)
best_tag = (name, th, area)
if best_pos is not None:
with self._remote_detect_lock:
self._remote_detect_last_pos = best_pos
self._save_remote_detect_debug_image(frame, best_pos, best_tag)
if self.logger:
self.logger.info(
f"[LASER-REMOTE] 检测到激光点 {best_pos} "
f"strategy={best_tag[0]} th={best_tag[1]} area={best_tag[2]:.1f}"
)
elif self.logger:
self.logger.debug("[LASER-REMOTE] 未通过面积/圆度过滤")
return best_pos
def _save_remote_detect_debug_image(self, frame, pos, tag=None):
"""保存远程识别调试图:叠加激光坐标并落盘。"""
try:
if not bool(getattr(config, "SAVE_IMAGE_ENABLED", True)):
return
import cv2
from maix import image
img_cv = image.image2cv(frame, False, False)
if img_cv is None or img_cv.size == 0:
return
x, y = int(pos[0]), int(pos[1])
h, w = img_cv.shape[:2]
if x < 0 or y < 0 or x >= w or y >= h:
return
cv2.circle(img_cv, (x, y), 8, (255, 0, 0), 2)
cv2.line(img_cv, (x - 12, y), (x + 12, y), (255, 0, 0), 1)
cv2.line(img_cv, (x, y - 12), (x, y + 12), (255, 0, 0), 1)
desc = ""
if tag:
desc = f" {tag[0]} th={tag[1]} area={tag[2]:.1f}"
cv2.putText(
img_cv,
f"laser=({x},{y}){desc}",
(10, 24),
cv2.FONT_HERSHEY_SIMPLEX,
0.55,
(255, 0, 0),
1,
cv2.LINE_AA,
)
base_dir = getattr(config, "PHOTO_DIR", "/root/phot")
debug_dir = f"{base_dir}/laser_remote"
try:
if debug_dir not in os.listdir("/root") and "/" not in debug_dir.replace("/root/", ""):
os.mkdir(debug_dir)
else:
try:
os.makedirs(debug_dir, exist_ok=True)
except Exception:
pass
except Exception:
try:
os.makedirs(debug_dir, exist_ok=True)
except Exception:
return
ts = int(time.ticks_ms())
filename = f"{debug_dir}/remote_{x}_{y}_{ts}.jpg"
out = image.cv2image(img_cv, False, False)
out.save(filename)
if self.logger:
self.logger.info(f"[LASER-REMOTE] 调试图已保存: {filename}")
except Exception as e:
if self.logger:
self.logger.warning(f"[LASER-REMOTE] 保存调试图失败: {e}")
def calibrate_laser_position(self, timeout_ms=8000, check_sharpness=True):
"""
执行激光校准:循环拍照 → 检测靶心 → 检查激光点清晰度 → 找红点 → 保存坐标
@@ -1664,6 +1233,28 @@ class LaserManager:
except Exception as e:
self.logger.error(f"[LASER] 关闭激光失败: {e}")
def set_hardcoded_laser_point(self, raw_x, raw_y):
"""
设置服务下发的硬编码激光点坐标,并保存到本地持久化文件。
下次启动时 load_laser_point() 会优先使用此保存的值。
Args:
raw_x: 服务下发的 x 坐标
raw_y: 服务下发的 y 坐标
Returns:
(int_x, int_y) 元组
"""
ix = int(raw_x)
iy = int(raw_y)
self._laser_point = (ix, iy)
try:
with open(config.CONFIG_FILE, "w") as f:
json.dump([ix, iy], f)
self.logger.info(f"[LASER] 设置并持久化激光点: ({ix}, {iy})")
except Exception as e:
self.logger.error(f"[LASER] 持久化激光点失败: {e}")
return ix, iy
# 创建全局单例实例
laser_manager = LaserManager()
+2 -2
View File
@@ -65,8 +65,8 @@ class LoggerManager:
backup_count = config.LOG_BACKUP_COUNT
try:
# 创建日志队列(界队列)
self._log_queue = queue.Queue(-1)
# 创建日志队列(界队列,防止内存泄漏;满时自动丢弃旧日志
self._log_queue = queue.Queue(maxsize=config.LOG_QUEUE_MAXSIZE)
# 确保日志文件所在的目录存在
log_dir = os.path.dirname(log_file)
+51 -53
View File
@@ -76,12 +76,14 @@ def laser_calibration_worker():
import traceback
traceback.print_exc()
time.sleep_ms(1000) # 等待1秒后继续
def cmd_str():
"""主程序入口"""
# ==================== 第一阶段:硬件初始化 ====================
# 按照 main104.py 的顺序,先完成所有硬件初始化
# 开机第一步先拉高 A14 关闭激光,避免其他硬件初始化期间误亮。
laser_manager.init_control_gpio()
# 1. 引脚功能映射
for pin, func in config.PIN_MAPPINGS.items():
try:
@@ -103,6 +105,8 @@ def cmd_str():
print(f"[BOOT] init_ina226 开始 wall_s={_w_boot:.3f}")
init_ina226()
print(f"[BOOT] init_ina226 结束 wall +{int(round((wall_time.time() - _w_boot) * 1000))} ms")
# 启动 A25 绿灯和 A23 红灯状态指示。
hardware_manager.start_status_led_monitor()
# 4. 初始化显示和相机
_w_boot = wall_time.time()
@@ -120,9 +124,9 @@ def cmd_str():
# ==================== 第二阶段:软件初始化 ====================
# 1. 初始化日志系统
# 1. 初始化日志系统WARNING级别,不打印/写入INFO和DEBUG日志,提高执行流畅度)
import logging
logger_manager.init_logging(log_level=logging.DEBUG)
logger_manager.init_logging(log_level=logging.WARNING)
logger = logger_manager.logger
# 补充:因为初始化的时候,激光会亮,先关了它
@@ -132,6 +136,7 @@ def cmd_str():
sync_system_time_from_4g()
# 2.1 WiFi 热点配网兜底:仅当 STA 与 4G 均不可用时起 AP + HTTP;提交后删 /boot/wifi.ap、建 wifi.sta 并 reboot
_ota_pending_path = f"{config.APP_DIR}/ota_pending.json"
try:
from wifi_config_httpd import maybe_start_wifi_ap_fallback
@@ -162,7 +167,11 @@ def cmd_str():
and _loc_black == "yolo"
and bool(getattr(config, "TRIANGLE_BLACK_YOLO_PRELOAD_ON_BOOT", True))
)
_preload_yolo = _preload_yolo or _need_black_preload
_need_target_preload = (
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:
preload_yolo_detector(logger)
except Exception as e:
@@ -245,8 +254,8 @@ def cmd_str():
# 4. 初始化设备IDnetwork_manager 内部会自动设置 device_id 和 password
network_manager.read_device_id()
# 5. 创建照片存储目录(如果启用图像保存)
if config.SAVE_IMAGE_ENABLED:
# 5. 创建照片存储目录(如果启用图像保存或检测失败时强制保存
if config.SAVE_IMAGE_ENABLED or getattr(config, "SAVE_IMAGE_ON_FAILURE", False):
photo_dir = config.PHOTO_DIR
if photo_dir not in os.listdir("/root"):
try:
@@ -278,46 +287,46 @@ def cmd_str():
logger.info("系统准备完成...")
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 拖慢采样
PRESSURE_BATCH_SIZE = 100
pressure_buf = []
pressure_sum = 0
pressure_abs_sum = 0
pressure_min = 4095
pressure_max = 0
pressure_t0_ms = None
last_avg_abs = 0
def _flush_pressure_buf(reason: str):
if not config.AIR_PRESSURE_lOG:
return
nonlocal pressure_buf, pressure_sum, pressure_min, pressure_max, pressure_t0_ms, logger, pressure_abs_sum, last_avg_abs
nonlocal pressure_buf, pressure_sum, pressure_min, pressure_max, pressure_t0_ms, logger
if not pressure_buf:
return
t1_ms = time.ticks_ms()
n = len(pressure_buf)
avg = (pressure_sum / n) if n else 0
avg_abs = (pressure_abs_sum / n) if n else 0
# 一行输出:方便后处理画曲线;同时带上统计信息便于快速看波峰
line = (
f"[气压批量] reason={reason} "
f"t0={pressure_t0_ms} t1={t1_ms} n={n} "
f"min={pressure_min} max={pressure_max} avg={avg:.1f} avg_abs={avg_abs:.3f} "
f"values={','.join(map(str, pressure_buf))}"
f" convert value (kpa): {(max(pressure_buf, key=lambda x: x[1])[1] - last_avg_abs) / (5 - 2.5) * config.AIR_PRESSURE_HARDWARE_MAX:.1f}"
)
if logger:
logger.debug(line)
else:
print(line)
if config.AIR_PRESSURE_lOG:
t1_ms = time.ticks_ms()
n = len(pressure_buf)
avg = (pressure_sum / n) if n else 0
line = (
f"[气压批量] reason={reason} "
f"t0={pressure_t0_ms} t1={t1_ms} n={n} "
f"min={pressure_min} max={pressure_max} avg={avg:.1f} "
f"values={','.join(map(str, pressure_buf))}"
)
if logger:
logger.debug(line)
else:
print(line)
# 无论是否记录日志,都必须清空 buffer,否则内存泄漏
pressure_buf = []
pressure_sum = 0
pressure_abs_sum = 0
pressure_min = 4095
pressure_max = 0
pressure_t0_ms = None
last_avg_abs = avg_abs
# 主循环:检测扳机触发 → 拍照 → 分析 → 上报
while not app.need_exit():
@@ -352,12 +361,10 @@ def cmd_str():
if network_manager.manual_trigger_flag:
network_manager.clear_manual_trigger()
adc_val = config.ADC_TRIGGER_THRESHOLD + 1
adc_abs_val = 10
if logger:
logger.info("[TEST] TCP命令触发射箭")
else:
adc_val = hardware_manager.adc_obj.read()
adc_abs_val = hardware_manager.adc_obj.read_vol()
except Exception as e:
logger = logger_manager.logger
if logger:
@@ -368,25 +375,24 @@ def cmd_str():
# ====== 气压采样缓存(每次循环都记录,批量输出日志)======
if pressure_t0_ms is None:
pressure_t0_ms = current_time
pressure_buf.append((adc_val, adc_abs_val))
pressure_buf.append(adc_val)
pressure_sum += adc_val
pressure_abs_sum += adc_abs_val
if adc_val < pressure_min:
pressure_min = adc_val
if adc_val > pressure_max:
pressure_max = adc_val
if len(pressure_buf) >= PRESSURE_BATCH_SIZE:
_flush_pressure_buf("batch")
# if adc_val >= 2000:
# print(f"adc :{adc_val}")
if adc_val >= config.ADC_TRIGGER_THRESHOLD:
# 突变增量检测:压力增量大于300时触发
# 触发后需等气压降到触发值以下才重新检测增量
if adc_val < trigger_adc_val :
enable_check = True
if (adc_val - last_adc_val) > 200 and enable_check:
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
# 触发前先把缓存刷出来,避免波形被长耗时处理截断
trigger_adc_val = adc_val # 记录触发时的气压值
last_adc_val = adc_val # 更新基准值,防止连续增量误触发
enable_check = False
_flush_pressure_buf("before_trigger")
try:
@@ -402,19 +408,11 @@ def cmd_str():
else:
if config.SHOW_CAMERA_PHOTO_WHILE_SHOOTING:
try:
frame = camera_manager.read_frame()
laser_manager.remote_detect_tick(frame)
if (
laser_manager.remote_detect_active
and getattr(config, "LASER_REMOTE_DETECT_DRAW_PREVIEW", False)
):
frame = laser_manager.overlay_remote_detect_preview(frame)
camera_manager.show(frame)
camera_manager.show(camera_manager.read_frame())
except Exception as e:
logger = logger_manager.logger
if logger:
logger.error(f"[MAIN] 显示异常: {e}")
time.sleep_ms(5)
pass
time.sleep_ms(1)
last_adc_val = adc_val
except Exception as e:
# 主循环的顶层异常捕获,防止程序静默退出
Binary file not shown.
+2 -2
View File
@@ -1,7 +1,7 @@
[basic]
type = cvimodel
model = model_270139.cvimodel
model = model_285484.cvimodel
[extra]
model_type = yolov5
@@ -9,5 +9,5 @@ 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 = 黑三角和圆环
labels = 20, 40
+481 -173
View File
File diff suppressed because it is too large Load Diff
+603
View File
@@ -0,0 +1,603 @@
#!/usr/bin/env python3
# PYTHON_ARGCOMPLETE_OK
import sys
import logging
import os
import re
import os.path
import collections
import uuid
import argparse
import tarfile
import io
from struct import pack, unpack
PYTHON_MIN_VERSION = (3, 5, 2) # Ubuntu 16.04 LTS contains Python v3.5.2 by default
if sys.version_info < PYTHON_MIN_VERSION:
print("Python >= %r is required" % (PYTHON_MIN_VERSION,))
sys.exit(-1)
try:
import coloredlogs
except ImportError:
coloredlogs = None
try:
import argcomplete
except ImportError:
argcomplete = None
TOC_HEADER_NAME = 0xAA640001
FIP_MAX_SIZE = 0xA0000
FIP_ALIGN_SIZE = 2 * 1024
ENTRY_SIZE = 0x28
IV_ZERO = b"\0" * 16
class FIP_HEADER_FLAG:
BitRange = collections.namedtuple("BitRange", "shift, bits")
REE_SCS = BitRange(0, 2)
REE_ENCRYPTION = BitRange(2, 2)
@classmethod
def test(cls, value, flag):
v = value >> flag.shift
v &= (1 << flag.bits) - 1
return v
@classmethod
def value(cls, flag):
v = (1 << flag.bits) - 1
v <<= flag.shift
return v
class FIP_UUID:
# from arm-trusted-firmware/include/tools_share/firmware_image_package.h
uuid_c_define = """
/* ToC Entry UUIDs */
#define UUID_LICENSE_FILE \
{0x25360c62, 0x5151, 0x48ad, 0xb5, 0x91, {0x2d, 0x35, 0x67, 0x26, 0x85, 0xa5} }
#define UUID_TRUSTED_UPDATE_FIRMWARE_SCP_BL2U \
{0x03279265, 0x742f, 0x44e6, 0x8d, 0xff, {0x57, 0x9a, 0xc1, 0xff, 0x06, 0x10} }
#define UUID_TRUSTED_UPDATE_FIRMWARE_BL2U \
{0x37ebb360, 0xe5c1, 0x41ea, 0x9d, 0xf3, {0x19, 0xed, 0xa1, 0x1f, 0x68, 0x01} }
#define UUID_TRUSTED_UPDATE_FIRMWARE_NS_BL2U \
{0x111d514f, 0xe52b, 0x494e, 0xb4, 0xc5, {0x83, 0xc2, 0xf7, 0x15, 0x84, 0x0a} }
#define UUID_TRUSTED_FWU_CERT \
{0xb28a4071, 0xd618, 0x4c87, 0x8b, 0x2e, {0xc6, 0xdc, 0xcd, 0x50, 0xf0, 0x96} }
#define UUID_TRUSTED_BOOT_FIRMWARE_BL2 \
{0x0becf95f, 0x224d, 0x4d3e, 0xa5, 0x44, {0xc3, 0x9d, 0x81, 0xc7, 0x3f, 0x0a} }
#define UUID_BLD \
{0x3dfd6697, 0xbe89, 0x49e8, 0xae, 0x5d, {0x78, 0xa1, 0x40, 0x60, 0x82, 0x13} }
#define UUID_EL3_RUNTIME_FIRMWARE_BL31 \
{0x6d08d447, 0xfe4c, 0x4698, 0x9b, 0x95, {0x29, 0x50, 0xcb, 0xbd, 0x5a, 0x00} }
#define UUID_SECURE_PAYLOAD_BL32 \
{0x89e1d005, 0xdc53, 0x4713, 0x8d, 0x2b, {0x50, 0x0a, 0x4b, 0x7a, 0x3e, 0x38} }
#define UUID_NON_TRUSTED_FIRMWARE_BL33 \
{0xa7eed0d6, 0xeafc, 0x4bd5, 0x97, 0x82, {0x99, 0x34, 0xf2, 0x34, 0xb6, 0xe4} }
/* Key certificates */
#define UUID_ROT_KEY_CERT \
{0x721d2d86, 0x60f8, 0x11e4, 0x92, 0x0b, {0x8b, 0xe7, 0x62, 0x16, 0x0f, 0x24} }
#define UUID_BLD1_KEY_CERT \
{0x90e87e82, 0x60f8, 0x11e4, 0xa1, 0xb4, {0x77, 0x7a, 0x21, 0xb4, 0xf9, 0x4c} }
#define UUID_BLD2_KEY_CERT \
{0xa1214202, 0x60f8, 0x11e4, 0x8d, 0x9b, {0xf3, 0x3c, 0x0e, 0x15, 0xa0, 0x14} }
#define UUID_SOC_FW_KEY_CERT \
{0xccbeb88a, 0x60f9, 0x11e4, 0x9a, 0xd0, {0xeb, 0x48, 0x22, 0xd8, 0xdc, 0xf8} }
#define UUID_TRUSTED_OS_FW_KEY_CERT \
{0x03d67794, 0x60fb, 0x11e4, 0x85, 0xdd, {0xb7, 0x10, 0x5b, 0x8c, 0xee, 0x04} }
#define UUID_BL33_KEY_CERT \
{0x2a83d58a, 0x60fb, 0x11e4, 0x8a, 0xaf, {0xdf, 0x30, 0xbb, 0xc4, 0x98, 0x59} }
/* Content certificates */
#define UUID_TRUSTED_BOOT_FW_CERT \
{0xea69e2d6, 0x635d, 0x11e4, 0x8d, 0x8c, {0x9f, 0xba, 0xbe, 0x99, 0x56, 0xa5} }
#define UUID_BLD_CONTENT_CERT \
{0x046fbe44, 0x635e, 0x11e4, 0xb2, 0x8b, {0x73, 0xd8, 0xea, 0xae, 0x96, 0x56} }
#define UUID_SOC_FW_CONTENT_CERT \
{0x200cb2e2, 0x635e, 0x11e4, 0x9c, 0xe8, {0xab, 0xcc, 0xf9, 0x2b, 0xb6, 0x66} }
#define UUID_TRUSTED_OS_FW_CONTENT_CERT \
{0x11449fa4, 0x635e, 0x11e4, 0x87, 0x28, {0x3f, 0x05, 0x72, 0x2a, 0xf3, 0x3d} }
#define UUID_BL33_CONTENT_CERT \
{0xf3c1c48e, 0x635d, 0x11e4, 0xa7, 0xa9, {0x87, 0xee, 0x40, 0xb2, 0x3f, 0xa7} }
/* CV keys */
#define UUID_CV_TRUSTED_KEY_CERT \
{0x64fbfc49, 0x4b8c, 0x4ad3, 0xb9, 0x92, {0x93, 0x55, 0x89, 0xee, 0xf0, 0x12} }
#define UUID_CV_NON_TRUSTED_KEY_CERT \
{0xcb48bf0d, 0x7012, 0x4201, 0xbc, 0x35, {0x8a, 0x51, 0xc4, 0x90, 0x90, 0x94} }
/* DDR init*/
#define UUID_CV_DDRINIT_KEY_CERT \
{0xa61c53c9, 0x886c, 0x484f, 0x96, 0x5d, {0xd2, 0xda, 0xd7, 0xc3, 0xeb, 0x13} }
#define UUID_CV_DDRINIT_CONTENT_CERT \
{0x9dfaabd2, 0x7f1b, 0x47e6, 0xa8, 0xa6, {0x6a, 0xc3, 0x10, 0xcc, 0xac, 0x91} }
#define UUID_CV_DDRINIT \
{0x5888a5cd, 0x38fc, 0x4f66, 0xae, 0x3d, {0x2e, 0x18, 0x6d, 0x69, 0x41, 0xfb} }
/* Fast boot */
#define UUID_CV_FASTBOOT_KEY_CERT \
{0x285df54e, 0x7b50, 0x4309, 0x9b, 0x52, {0x4b, 0xc4, 0x92, 0x82, 0x60, 0xdd} }
#define UUID_CV_FASTBOOT_CONTENT_CERT \
{0x61f7595b, 0x8d77, 0x4e13, 0x91, 0x2a, {0x63, 0x6e, 0x58, 0xda, 0x5b, 0x69} }
#define UUID_CV_FASTBOOT \
{0x43766198, 0xc363, 0x48db, 0xa9, 0x97, {0xf1, 0x0e, 0x93, 0x80, 0x4f, 0xea} }
"""
@classmethod
def cls_init(cls):
txt = cls.uuid_c_define
txt = txt.replace("\r\n", "\n")
txt = txt.replace("\\\n", "\n")
rx = r"""
\#define\s+
(?P<name>\S+)\s+
{
\s*(?P<u0>0x\S+)\s*,\s*
\s*(?P<u1>0x\S+)\s*,\s*
\s*(?P<u2>0x\S+)\s*,\s*
\s*(?P<u3>0x\S+)\s*,\s*
\s*(?P<u4>0x\S+)\s*,\s*
{
\s*(?P<u5>0x\S+)\s*,\s*
\s*(?P<u6>0x\S+)\s*,\s*
\s*(?P<u7>0x\S+)\s*,\s*
\s*(?P<u8>0x\S+)\s*,\s*
\s*(?P<u9>0x\S+)\s*,\s*
\s*(?P<u10>0x\S+)\s*
}\s*,?\s*
}
"""
for m in re.finditer(rx, txt, flags=re.X):
name = m.group("name")
u = m.group(*["u%d" % i for i in range(11)])
u = [int(i, 0) for i in u]
u = pack("<IHHBBBBBBBB", *u)
u = uuid.UUID(bytes=u)
setattr(cls, name, u)
class Entry:
__slots__ = ["name", "loc", "uuid", "address", "flag", "content"]
def __init__(self):
self.loc = 0
self.uuid = uuid.UUID(int=0)
self.address = 0
self.flag = 0
self.content = b""
@classmethod
def make(cls, uuid, content):
entry = cls()
entry.uuid = uuid
entry.content = content
return entry
@classmethod
def from_fip(cls, name, loc, fip_bin):
data = fip_bin[loc : loc + ENTRY_SIZE]
uuid_bytes, address, size, flag = unpack("<16sQQQ", data)
content = fip_bin[address : address + size]
entry = cls()
entry.name = name
entry.loc = loc
entry.uuid = uuid.UUID(bytes=uuid_bytes)
entry.address = address
entry.flag = flag
entry.content = content
return entry
def to_bytes(self):
return pack("<16sQQQ", self.uuid.bytes, self.address, self.size, self.flag)
@property
def size(self):
return len(self.content)
@property
def end(self):
return self.address + self.size
def __str__(self):
return "<%-31s loc=0x%03x U=%s a=0x%05x,0x%05x,0x%05x f=0x%x>" % (
self.name,
self.loc,
self.uuid.hex[:8],
self.address,
self.end,
self.size,
self.flag,
)
class FIP:
ENTRY_NAMES = collections.OrderedDict(
[
("LICENSE_FILE", "UUID_LICENSE_FILE"),
("BL2", "UUID_TRUSTED_BOOT_FIRMWARE_BL2"),
("BLD", "UUID_BLD"),
("BL31", "UUID_EL3_RUNTIME_FIRMWARE_BL31"),
("BL32", "UUID_SECURE_PAYLOAD_BL32"),
("BL33", "UUID_NON_TRUSTED_FIRMWARE_BL33"),
("BLD1_KEY_CERT", "UUID_BLD1_KEY_CERT"),
("BLD2_KEY_CERT", "UUID_BLD2_KEY_CERT"),
("CV_TRUSTED_KEY_CERT", "UUID_CV_TRUSTED_KEY_CERT"),
("SOC_FW_KEY_CERT", "UUID_SOC_FW_KEY_CERT"),
("TRUSTED_OS_FW_KEY_CERT", "UUID_TRUSTED_OS_FW_KEY_CERT"),
("CV_NON_TRUSTED_KEY_CERT", "UUID_CV_NON_TRUSTED_KEY_CERT"),
("BL33_KEY_CERT", "UUID_BL33_KEY_CERT"),
("TRUSTED_BOOT_FW_CERT", "UUID_TRUSTED_BOOT_FW_CERT"),
("BLD_CONTENT_CERT", "UUID_BLD_CONTENT_CERT"),
("SOC_FW_CONTENT_CERT", "UUID_SOC_FW_CONTENT_CERT"),
("TRUSTED_OS_FW_CONTENT_CERT", "UUID_TRUSTED_OS_FW_CONTENT_CERT"),
("BL33_CONTENT_CERT", "UUID_BL33_CONTENT_CERT"),
("CV_DDRINIT", "UUID_CV_DDRINIT"),
("CV_FASTBOOT", "UUID_CV_FASTBOOT"),
]
)
TOC_Header = collections.namedtuple(
"TOC_Header", "name, serial, flag_res, flag_plat, flag_res2"
)
def __init__(self, path):
logging.info("FIP_BIN: %s", path)
self.path = path
def load(self):
with open(self.path, "rb") as fp:
self.binary = fp.read(FIP_MAX_SIZE)
logging.info("%s is %d bytes", self.path, len(self.binary))
self.header = self.TOC_Header(*unpack("<IIIHH", self.binary[0x00:0x10]))
if self.header.name != TOC_HEADER_NAME:
raise ValueError(
"FIP header is 0x%08x but should be 0x%08x"
% (self.header[0], TOC_HEADER_NAME)
)
logging.info("TOC header: flag_plat=0x%04x", self.header.flag_plat)
logging.info(
" REE_SCS: %r",
FIP_HEADER_FLAG.test(self.header.flag_plat, FIP_HEADER_FLAG.REE_SCS),
)
logging.info(
" REE_ENCRYPTION: %r",
FIP_HEADER_FLAG.test(self.header.flag_plat, FIP_HEADER_FLAG.REE_ENCRYPTION),
)
ents = []
for k, v in self.ENTRY_NAMES.items():
try:
ents.append((k, self.find_entry(v)))
except ValueError as err:
logging.warning("%s", err)
ents.sort(key=lambda x: x[1].address)
for n, (k, v) in enumerate(ents):
logging.debug("%s", v)
if n > 0:
pk, pv = ents[n - 1]
if v.loc != pv.loc + ENTRY_SIZE or v.address != pv.address + pv.size:
raise Exception("Invalid FIP")
rest = self.binary[ents[-1][1].end :]
loc = rest.find(b"APLB")
if loc < 0:
raise Exception("No BLD/DDRC")
self.blp_ddrc_binary = rest[loc:]
logging.debug("blp_ddrc: 0x%04x at 0x%08x", len(self.blp_ddrc_binary), loc)
self.ents = collections.OrderedDict(ents)
def make_fip(self, output_path=None):
logging.info("New TOC header: flag_plat=0x%04x", self.header.flag_plat)
header_bin = pack("<IIIHH", *self.header)
fip_bin = header_bin
# Sort self.ents by the order of FIP.ENTRY_NAMES
sorted_ents = collections.OrderedDict()
for name in self.ENTRY_NAMES:
try:
sorted_ents[name] = self.ents[name]
except KeyError:
pass
self.ents = sorted_ents
offset = (len(self.ents) + 1) * ENTRY_SIZE + 0x10
for k, v in self.ents.items():
v.address = offset
fip_bin += v.to_bytes()
offset += v.size
null_entry = Entry()
null_entry.address = offset
fip_bin += null_entry.to_bytes()
for k, v in self.ents.items():
fip_bin += v.content
if (len(fip_bin) % FIP_ALIGN_SIZE) > 0:
fip_bin += b"\x00" * (FIP_ALIGN_SIZE - len(fip_bin) % FIP_ALIGN_SIZE)
fip_bin += self.blp_ddrc_binary
if output_path:
path = output_path
else:
path = os.path.splitext(self.path)
path = path[0] + "_signed_encrypted" + path[1]
logging.info("Save new FIP image to %s", path)
with open(path, "wb") as fp:
fp.write(fip_bin)
def dump_uuids(self):
for k, v in vars(FIP_UUID).items():
if k.startswith("UUID_"):
print("%-38s" % k, v.hex)
def find_entry(self, name):
# UUID=0, offset=any, size=0, flags=0
nullm = re.search(rb"\0{16}.{8}\0{16}", self.binary, flags=re.DOTALL)
if nullm is None:
raise Exception("NULL TOC entry is not found")
max_toc_size = nullm.start(0)
uuid = getattr(FIP_UUID, name)
loc = self.binary.find(uuid.bytes, 0, max_toc_size)
if loc < 0:
raise ValueError("%s is not found" % name)
return Entry.from_fip(name, loc, self.binary)
def entry(args):
logging.debug("cmd_fip")
def init_logging(log_file=None, file_level="DEBUG", stdout_level="WARNING"):
root_logger = logging.getLogger()
root_logger.setLevel(logging.NOTSET)
fmt = "%(asctime)s %(levelname)8s:%(name)s:%(message)s"
if log_file is not None:
file_handler = logging.FileHandler(log_file, encoding="utf-8")
file_handler.setFormatter(logging.Formatter(fmt))
file_handler.setLevel(file_level)
root_logger.addHandler(file_handler)
if coloredlogs:
os.environ["COLOREDLOGS_DATE_FORMAT"] = "%H:%M:%S"
field_styles = {
"asctime": {"color": "green"},
"hostname": {"color": "magenta"},
"levelname": {"color": "black", "bold": True},
"name": {"color": "blue"},
"programname": {"color": "cyan"},
}
level_styles = coloredlogs.DEFAULT_LEVEL_STYLES
level_styles["debug"]["color"] = "cyan"
coloredlogs.install(
level=stdout_level,
fmt=fmt,
field_styles=field_styles,
level_styles=level_styles,
milliseconds=True,
)
def parse_fip(fip_path):
logging.debug("parse_fip: %s", fip_path)
fip = FIP(fip_path)
fip.load()
def unpack_fip(fip_path):
logging.debug("unpack_fip: %s", fip_path)
fip = FIP(fip_path)
fip.load()
def save(name, content):
fn = os.path.splitext(fip_path)
fn = "%s_%s%s" % (fn[0], name, fn[1])
logging.info("Save %s", fn)
with open(fn, "wb") as fp:
fp.write(content)
for k, v in fip.ents.items():
save(k, v.content)
save("BLP_DDRC", fip.blp_ddrc_binary)
def tar_bld(fip_path, output_path, multibin):
logging.debug("tar_bld: %s multibin=%r", fip_path, multibin)
fip = FIP(fip_path)
fip.load()
members = [
"BLD_CONTENT_CERT",
"BLD2_KEY_CERT",
"BLD1_KEY_CERT",
"CV_DDRINIT" if multibin else "BLD",
]
if not output_path:
output_path = os.path.join(os.path.dirname(fip_path), "bld.tar")
logging.info("bld_tar_path=%s", output_path)
with tarfile.open(output_path, "w") as tf:
for m in members:
logging.debug("Tar %s", m)
try:
fp = io.BytesIO(fip.ents[m].content)
except KeyError:
logging.warning("%s doesn't exist", m)
continue
info = tarfile.TarInfo(name=m + ".bin")
info.size = len(fp.getbuffer())
tf.addfile(tarinfo=info, fileobj=fp)
def merge_fip(fip_path, inputs, output_path):
logging.debug("merge_fip: %s", fip_path)
fip = FIP(fip_path)
fip.load()
for name in FIP.ENTRY_NAMES:
binary = inputs.get(name)
if not binary:
continue
logging.debug("merge %s", name)
ent = fip.ents.get(name)
if ent:
ent.content = binary
else:
ent = Entry.make(getattr(FIP_UUID, "UUID_" + name), binary)
fip.ents[name] = ent
binary = inputs.get("BLP_DDRC")
if binary:
fip.blp_ddrc_binary = binary
if not output_path:
fn = os.path.splitext(fip_path)
fn = "%s_%s%s" % (fn[0], "merged", fn[1])
output_path = fn
fip.make_fip(output_path)
def round_up(n, k):
return (n + k - 1) // k * k
def read_blp_and_ddrc(inputs, blp_path, ddrc_path):
logging.info("Open %s and %s", blp_path, ddrc_path)
with open(blp_path, "rb") as fp:
blp_bin = fp.read()
logging.info("Open %s", ddrc_path)
with open(ddrc_path, "rb") as fp:
ddrc_bin = fp.read()
blp_bin += b"\0" * (round_up(len(blp_bin), FIP_ALIGN_SIZE) - len(blp_bin))
ddrc_bin += b"\0" * (round_up(len(ddrc_bin), FIP_ALIGN_SIZE) - len(ddrc_bin))
inputs["BLP_DDRC"] = blp_bin + ddrc_bin
def read_bld_tar(inputs, bld_tar_path, multibin):
logging.info("Open %s multibin=%r", bld_tar_path, multibin)
members = [
"BLD_CONTENT_CERT.bin",
"BLD2_KEY_CERT.bin",
"BLD1_KEY_CERT.bin",
"CV_DDRINIT.bin" if multibin else "BLD.bin",
]
with tarfile.open(bld_tar_path, "r") as tf:
for member in members:
try:
fp = tf.extractfile(member)
inputs[os.path.splitext(member)[0]] = fp.read()
except KeyError:
logging.warning("%s does not exist", member)
def main():
parser = argparse.ArgumentParser(description="FIP packer")
for name in FIP.ENTRY_NAMES:
parser.add_argument(
"--add-%s" % name.lower(),
dest=name,
type=str,
help="Merge %s into FIP" % name,
)
parser.add_argument(
"--add-blp-ddrc", dest="BLP_DDRC", type=str, help="Merge BLP+DDRC into FIP"
)
parser.add_argument("--add-blp", dest="BLP", type=str, help="Merge BLP into FIP")
parser.add_argument("--add-ddrc", dest="DDRC", type=str, help="Merge DDRC into FIP")
parser.add_argument(
"--add-bld-tar", dest="BLD_TAR", type=str, help="Merge BLD.tar into FIP"
)
parser.add_argument("--multibin", action="store_true", help="Use multibin")
parser.add_argument("FIP_BIN", type=str, nargs=1, help="Input FIP binary")
parser.add_argument("--output", type=str, help="Output filename")
parser.add_argument(
"--version", action="store_true", help="Output version information and exit"
)
parser.add_argument(
"--verbose",
help="Increase output verbosity",
action="store_const",
const=logging.DEBUG,
default=logging.DEBUG,
)
parser.add_argument("--unpack", action="store_true", help="Unpack FIP.bin")
parser.add_argument("--parse", action="store_true", help="Parse FIP.bin")
parser.add_argument(
"--tar-bld", action="store_true", help="Extrace BLD.bin and tar"
)
if argcomplete:
argcomplete.autocomplete(parser)
args = parser.parse_args()
init_logging(stdout_level=args.verbose)
logging.debug("args=%r", args)
FIP_UUID.cls_init()
if args.parse:
parse_fip(args.FIP_BIN[0])
if args.unpack:
unpack_fip(args.FIP_BIN[0])
if args.tar_bld:
tar_bld(args.FIP_BIN[0], args.output, args.multibin)
inputs = collections.OrderedDict()
for name in list(FIP.ENTRY_NAMES) + ["BLP_DDRC"]:
fn = getattr(args, name)
if not fn:
continue
logging.info("Open %s", fn)
with open(fn, "rb") as fp:
inputs[name] = fp.read()
if args.BLP or args.DDRC:
read_blp_and_ddrc(inputs, args.BLP, args.DDRC)
if args.BLD_TAR:
read_bld_tar(inputs, args.BLD_TAR, args.multibin)
if len(inputs):
merge_fip(args.FIP_BIN[0], inputs, args.output)
if __name__ == "__main__":
main()
+57
View File
@@ -0,0 +1,57 @@
#!/bin/sh
# OTA 更新脚本 - 使用 curl 断点下载
# 用法: sh ota_curl.sh <下载URL>
# 示例: sh ota_curl.sh http://example.com/maix-t11-v2.15.1.zip
set -e
APP_DIR="/maixapp/apps/t11"
BACKUP_BASE="$APP_DIR/backups"
TMP_DIR="/tmp/ota_curl"
PENDING_FILE="$APP_DIR/ota_pending.json"
if [ $# -lt 1 ]; then
echo "用法: $0 <下载URL>"
exit 1
fi
OTA_URL="$1"
FILENAME=$(basename "$OTA_URL" | sed 's/?.*//')
[ -z "$FILENAME" ] && FILENAME="update.zip"
mkdir -p "$TMP_DIR" "$BACKUP_BASE"
# 1. 断点下载
echo "[OTA] 开始下载: $OTA_URL"
echo "[OTA] 保存到: $TMP_DIR/$FILENAME"
curl -C - -L --retry 3 --retry-delay 5 -o "$TMP_DIR/$FILENAME" "$OTA_URL"
echo "[OTA] 下载完成"
# 2. 备份当前目录
TIMESTAMP=$(date +%Y%m%d_%H%M%S 2>/dev/null || echo "00000000_000000")
BACKUP_DIR="$BACKUP_BASE/backup_$TIMESTAMP"
mkdir -p "$BACKUP_DIR"
echo "[OTA] 备份到: $BACKUP_DIR"
for f in "$APP_DIR"/*.py "$APP_DIR"/*.json "$APP_DIR"/*.xml "$APP_DIR"/*.yaml "$APP_DIR"/*.pem "$APP_DIR"/*.mud "$APP_DIR"/*.so "$APP_DIR"/S99archery; do
[ -f "$f" ] && cp "$f" "$BACKUP_DIR/"
done
echo "[OTA] 备份完成"
# 3. 解压并替换文件
echo "[OTA] 开始更新..."
if echo "$FILENAME" | grep -qi '\.zip$'; then
unzip -q -o "$TMP_DIR/$FILENAME" -d "$APP_DIR/"
else
cp "$TMP_DIR/$FILENAME" "$APP_DIR/"
fi
sync
# 4. 写入 pending 文件(用于崩溃恢复)
echo '{"ts":0,"url":"'"$OTA_URL"'","backup_dir":"'"$BACKUP_DIR"'","restart_count":0,"max_restarts":3}' > "$PENDING_FILE"
sync
echo "[OTA] 更新完成,准备重启..."
# 5. 重启
sleep 1
reboot
+20 -9
View File
@@ -5,11 +5,14 @@
提供电压、电流监测和充电状态检测
"""
import config
import os
import subprocess
import _thread
from logger_manager import logger_manager
from maix import time as maix_time
_INA226_PRESENT = None
_INA226_LOCK = _thread.allocate_lock()
def _ina226_ready() -> bool:
@@ -31,7 +34,11 @@ def write_register(reg, value):
data = [(value >> 8) & 0xFF, value & 0xFF]
# 某些底层驱动在失败时只打印 “write failed” 并返回 -1,而不是抛异常;
# 为避免误判“初始化成功”导致后续 readfrom_mem SIGSEGV,这里把失败显式转成异常。
ret = hardware_manager.bus.writeto_mem(config.INA226_ADDR, reg, bytes(data))
_INA226_LOCK.acquire()
try:
ret = hardware_manager.bus.writeto_mem(config.INA226_ADDR, reg, bytes(data))
finally:
_INA226_LOCK.release()
if isinstance(ret, int) and ret < 0:
if logger:
logger.error(f"[INA226] writeto_mem 失败: addr=0x{config.INA226_ADDR:02X} reg=0x{reg:02X} ret={ret}")
@@ -41,7 +48,11 @@ def write_register(reg, value):
def read_register(reg):
"""读取INA226寄存器"""
from hardware import hardware_manager
data = hardware_manager.bus.readfrom_mem(config.INA226_ADDR, reg, 2)
_INA226_LOCK.acquire()
try:
data = hardware_manager.bus.readfrom_mem(config.INA226_ADDR, reg, 2)
finally:
_INA226_LOCK.release()
return (data[0] << 8) | data[1]
@@ -85,7 +96,7 @@ def get_bus_voltage():
def get_current():
"""
读取电流(单位:mA
正数表示电,负数表示放电
当前电源板实测:正数表示电,负数表示充电。
INA226 电流计算公式:
Current = (Current Register Value) × Current_LSB
@@ -96,13 +107,13 @@ def get_current():
return 0.0
raw = read_register(config.REG_CURRENT)
# INA226 电流寄存器是16位有符号整数
# 最高位是符号位0=正(充电),1=负(放电)
# 最高位是符号位;电流方向含义取决于电源板的采样电阻接线方向。
# 计算 Current_LSB(根据 CALIBRATION_VALUE
current_lsb = 0.001 * config.CALIBRATION_VALUE / 4096 # 单位:A
# 处理有符号数:如果最高位为1,转换为负数
if raw & 0x8000: # 最高位为1,表示负数(放电)
if raw & 0x8000:
signed_raw = raw - 0x10000 # 转换为有符号整数
else: # 最高位为0,表示正数(充电)
else:
signed_raw = raw
# 转换为毫安
current_ma = signed_raw * current_lsb * 1000
@@ -129,7 +140,7 @@ def is_charging(threshold_ma=10.0):
"""
try:
current = get_current()
is_charge = current > threshold_ma
is_charge = current < -abs(float(threshold_ma))
return is_charge
except Exception as e:
logger = logger_manager.logger
@@ -159,7 +170,7 @@ def voltage_to_percent(voltage):
return 0
if v <= 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:
+48 -156
View File
@@ -8,7 +8,7 @@ from laser_manager import laser_manager
from logger_manager import logger_manager
from network import network_manager
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, enqueue_save_raw_shot
from vision import estimate_distance, detect_circle_v3, enqueue_save_shot
from maix import image, time
# 缓存相机标定与三角形位置,避免每次射箭重复读磁盘
@@ -58,7 +58,6 @@ def analyze_shot(frame, laser_point=None):
# ── Step 1: 确定激光点 ────────────────────────────────────────────────────
laser_point_method = None
distance_m_first = None
best_radius1_temp = None
if config.HARDCODE_LASER_POINT:
laser_point = laser_manager.laser_point
@@ -103,22 +102,9 @@ def analyze_shot(frame, laser_point=None):
r_img, center, radius, method, best_radius1, ellipse_params = cdata
dx, dy = None, None
d_m = distance_m_first
tri_h = None
if center and radius:
dx, dy = laser_manager.compute_laser_position(center, (x, y), radius, method)
d_m = estimate_distance(best_radius1) if best_radius1 else distance_m_first
try:
import numpy as _np
px_per_cm = float(radius) / 10.0
if px_per_cm > 1e-6:
cxp, cyp = float(center[0]), float(center[1])
tri_h = _np.array([
[1.0 / px_per_cm, 0.0, -cxp / px_per_cm],
[0.0, 1.0 / px_per_cm, -cyp / px_per_cm],
[0.0, 0.0, 1.0],
], dtype=float)
except Exception:
tri_h = None
out = {
"success": True,
"result_img": r_img,
@@ -128,7 +114,6 @@ def analyze_shot(frame, laser_point=None):
"laser_point": laser_point, "laser_point_method": laser_point_method,
"offset_method": "yellow_ellipse" if ellipse_params else "yellow_circle",
"distance_method": "yellow_radius",
"tri_homography": tri_h,
}
if yolo_roi_xyxy is not None:
out["yolo_roi_xyxy"] = yolo_roi_xyxy
@@ -144,10 +129,8 @@ def analyze_shot(frame, laser_point=None):
roi_xyxy = None
yolo_ring_ms = 0.0
yolo_black_ms = 0.0
_timing_on = bool(getattr(config, "ARCHERY_TIMING_ENABLE", True))
_sample_on = bool(getattr(config, "TRIANGLE_SAMPLE_ENABLE", False))
if getattr(config, "TRIANGLE_YOLO_ROI_ENABLE", False):
_t_yolo_ring = time_std.perf_counter() if _timing_on else None
_t_yolo_ring = time_std.perf_counter()
try:
from target_roi_yolo import try_get_triangle_roi_from_yolo
roi_xyxy = try_get_triangle_roi_from_yolo(
@@ -157,8 +140,7 @@ def analyze_shot(frame, laser_point=None):
if logger:
logger.warning(f"[YOLO-ROI] {e}")
finally:
if _timing_on and _t_yolo_ring is not None:
yolo_ring_ms = (time_std.perf_counter() - _t_yolo_ring) * 1000.0
yolo_ring_ms = (time_std.perf_counter() - _t_yolo_ring) * 1000.0
_loc_mode = str(
getattr(config, "TRIANGLE_BLACK_TRIANGLE_LOCATE_MODE", "yolo")
@@ -173,7 +155,7 @@ def analyze_shot(frame, laser_point=None):
and roi_xyxy is not None
)
if _run_stage2_black_yolo:
_t_yolo_black = time_std.perf_counter() if _timing_on else None
_t_yolo_black = time_std.perf_counter()
try:
from target_roi_yolo import try_black_triangle_boxes_work
@@ -184,8 +166,7 @@ def analyze_shot(frame, laser_point=None):
if logger:
logger.warning(f"[YOLO-BLACK] {e}")
finally:
if _timing_on and _t_yolo_black is not None:
yolo_black_ms = (time_std.perf_counter() - _t_yolo_black) * 1000.0
yolo_black_ms = (time_std.perf_counter() - _t_yolo_black) * 1000.0
elif (
logger
and _loc_mode == "traditional"
@@ -203,7 +184,7 @@ def analyze_shot(frame, laser_point=None):
try:
logger.info(f"[TRI] begin {datetime.now()}")
logger.info(f"[TRI] K: {K}, dist: {dist_coef}, pos: {pos}, {datetime.now()}")
_t_wall_try = time_std.perf_counter() if _timing_on else None
_t_wall_try = time_std.perf_counter()
tri = try_triangle_scoring(
img_cv, (x, y), pos, K, dist_coef,
size_range=getattr(config, "TRIANGLE_SIZE_RANGE", (8, 500)),
@@ -212,8 +193,8 @@ def analyze_shot(frame, laser_point=None):
yolo_ring_ms=yolo_ring_ms,
yolo_black_ms=yolo_black_ms,
)
_wall_try_ms = (time_std.perf_counter() - _t_wall_try) * 1000.0 if _timing_on else 0.0
if logger and bool(getattr(config, "TRIANGLE_LOG_E2E_TIMING", True)) and _timing_on:
_wall_try_ms = (time_std.perf_counter() - _t_wall_try) * 1000.0
if logger and bool(getattr(config, "TRIANGLE_LOG_E2E_TIMING", True)):
_e2e = float(yolo_ring_ms) + float(yolo_black_ms) + float(_wall_try_ms)
logger.info(
f"[TRI] timing_e2e_triangle_ms={_e2e:.1f} "
@@ -299,16 +280,6 @@ def analyze_shot(frame, laser_point=None):
"tri_markers_completed": tri.get("markers_completed", []),
"tri_homography": tri.get("homography"),
}
try:
import numpy as _np
_H = tri.get("homography")
if _H is not None and _np.all(_np.isfinite(_H)):
_H_inv = _np.linalg.inv(_H)
_pt = _np.array([[[0.0, 0.0]]], dtype=_np.float32)
_center_pt = cv2.perspectiveTransform(_pt, _H_inv)[0][0]
out["tri_center_px"] = [float(_center_pt[0]), float(_center_pt[1])]
except Exception:
pass
if yolo_roi_xyxy is not None:
out["yolo_roi_xyxy"] = yolo_roi_xyxy
return out
@@ -347,21 +318,24 @@ def process_shot(adc_val):
:return: None
"""
logger = logger_manager.logger
_timing_on = bool(getattr(config, "ARCHERY_TIMING_ENABLE", True))
try:
network_manager.safe_enqueue({"shoot_event": "start"}, msg_type=2, high=True)
frame = camera_manager.read_frame()
from shot_id_generator import shot_id_generator
shot_id = shot_id_generator.generate_id()
# 网络事件移到拍照之后,避免阻塞拍照
network_manager.safe_enqueue({"shoot_event": "start"}, msg_type=2, high=True)
if getattr(config, "SAVE_RAW_SHOT_IMAGE_ENABLED", False):
enqueue_save_raw_shot(
frame,
shot_id=shot_id,
photo_dir=config.PHOTO_DIR if config.SAVE_IMAGE_ENABLED else None,
)
# 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)
@@ -396,107 +370,6 @@ def process_shot(adc_val):
)
x, y = laser_point
# 物方采样调试(config.TRIANGLE_SAMPLE_ENABLE):靶心为原点,取两个对称点判断黑白来区分 40/20 标靶
# 逻辑:若两个采样点 RGB 均 < 阈值 → 全黑 → 40cm 标靶;否则 → 20cm 标靶
sample_target_type = None
_t_sample = time_std.perf_counter() if _timing_on else None
_t_sample_ms = 0.0
sample_points = []
sample_patch_half = 2
if bool(getattr(config, "TRIANGLE_SAMPLE_ENABLE", False)):
sample_obj_radius_cm = float(getattr(config, "TRIANGLE_SAMPLE_RADIUS_CM", 15.0))
sample_obj_angles_deg = (0, 180) # 只取两个对称点:+X 和 -X
sample_patch_half = int(getattr(config, "TRIANGLE_SAMPLE_PATCH_HALF_PX", 2))
sample_black_thresh = float(getattr(config, "TRIANGLE_SAMPLE_BLACK_THRESH", 30.0))
try:
import math as _math
import numpy as _np
import cv2 as _cv2
if tri_homography is not None:
_H_inv = _np.linalg.inv(tri_homography)
for _ang in sample_obj_angles_deg:
_rad = _math.radians(float(_ang))
_pt_obj = _np.array([
[[sample_obj_radius_cm * _math.cos(_rad), sample_obj_radius_cm * _math.sin(_rad)]]
], dtype=_np.float32)
_pt_img = _cv2.perspectiveTransform(_pt_obj, _H_inv)[0][0]
_px, _py = float(_pt_img[0]), float(_pt_img[1])
sample_points.append({
"angle_deg": float(_ang),
"obj_cm": (float(sample_obj_radius_cm * _math.cos(_rad)), float(sample_obj_radius_cm * _math.sin(_rad))),
"img_px": (int(round(_px)), int(round(_py))),
})
elif center and radius:
_px_per_cm = float(radius) / 10.0
for _ang in sample_obj_angles_deg:
_rad = _math.radians(float(_ang))
_px = float(center[0]) + sample_obj_radius_cm * _math.cos(_rad) * _px_per_cm
_py = float(center[1]) + sample_obj_radius_cm * _math.sin(_rad) * _px_per_cm
sample_points.append({
"angle_deg": float(_ang),
"obj_cm": (float(sample_obj_radius_cm * _math.cos(_rad)), float(sample_obj_radius_cm * _math.sin(_rad))),
"img_px": (int(round(_px)), int(round(_py))),
})
# 取样后立即读像素并判断黑白:三角成功用 H_inv;三角失败但圆心成功用 center/radius 近似物方半径
_all_black = False
_sample_infos = []
if sample_points:
_img_cv_for_sample = image.image2cv(result_img, False, False)
_all_black = True
for _sp in sample_points:
_sx, _sy = _sp["img_px"]
_hh = max(1, sample_patch_half)
_patch = []
for _yy in range(_sy - _hh, _sy + _hh + 1):
if _yy < 0 or _yy >= _img_cv_for_sample.shape[0]:
continue
for _xx in range(_sx - _hh, _sx + _hh + 1):
if _xx < 0 or _xx >= _img_cv_for_sample.shape[1]:
continue
_patch.append(_img_cv_for_sample[_yy, _xx].astype(float))
if _patch:
_mean_rgb = _np.mean(_patch, axis=0)
_is_black = bool(_mean_rgb[0] < sample_black_thresh
and _mean_rgb[1] < sample_black_thresh
and _mean_rgb[2] < sample_black_thresh)
if not _is_black:
_all_black = False
_sample_infos.append(
f"{int(_sp['angle_deg'])}°@{_sx},{_sy} rgb=({int(_mean_rgb[0])},{int(_mean_rgb[1])},{int(_mean_rgb[2])})"
)
sample_target_type = "40cm_black" if _all_black else "20cm"
if _sample_infos:
logger.info("[采样] " + " | ".join(_sample_infos) + f"{sample_target_type}")
except Exception as _e_sample:
sample_points = []
if logger:
logger.warning(f"[采样] 标靶类型判断失败: {_e_sample}")
if _timing_on and _t_sample is not None:
_t_sample_ms = (time_std.perf_counter() - _t_sample) * 1000.0
# 采样提前完成后,先确定靶型对应的物理半径,供后续距离/偏移/上报使用。
# 40cm_black 表示直径40cm,半径20cm20cm 表示直径20cm,半径10cm。
target_radius_cm = 20.0 if sample_target_type == "40cm_black" else (10.0 if sample_target_type == "20cm" else 20.0)
target_type_value = 40 if sample_target_type == "40cm_black" else (20 if sample_target_type == "20cm" else None)
# 圆心分支原算法默认按40cm靶半径20cm换算;若采样判定为20cm靶,在上报前修正距离和偏移。
# 三角分支使用 triangle_positions.json 的物方坐标,不在这里二次缩放,避免影响三角单应性结果。
if sample_target_type == "20cm" and center and radius and not tri_markers:
try:
distance_m = (target_radius_cm * config.FOCAL_LENGTH_PIX) / float(radius) / 100.0
_scale = target_radius_cm / 20.0
if dx is not None:
dx = float(dx) * _scale
if dy is not None:
dy = float(dy) * _scale
if logger:
logger.info(f"[采样] 20cm靶修正圆心测距/偏移: distance={distance_m:.2f}m scale={_scale:.2f}")
except Exception as _e_fix:
if logger:
logger.warning(f"[采样] 20cm靶修正失败: {_e_fix}")
# 三角形路径成功时 center/radius 为空是正常的;此时用 triangle 方法名用于保存文件名与上报字段 m
if (not method) and tri_markers:
method = "triangle_homography"
@@ -507,6 +380,10 @@ def process_shot(adc_val):
if dx is None and dy is None and logger:
logger.warning("[MAIN] 未检测到偏移量(三角形与圆形均失败),但会保存图像")
# 生成射箭ID
from shot_id_generator import shot_id_generator
shot_id = shot_id_generator.generate_id()
if logger:
logger.info(f"[MAIN] 射箭ID: {shot_id}")
@@ -519,11 +396,25 @@ def process_shot(adc_val):
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 = {
"shot_id": shot_id,
"x": srv_x,
"y": srv_y,
"r": target_radius_cm, # 物理靶半径 cm40cm靶=2020cm靶=10
"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_laser": round((laser_distance_m or 0.0) * 100),
"d_laser_quality": laser_signal_quality,
@@ -534,7 +425,6 @@ def process_shot(adc_val):
"target_y": float(y),
"offset_method": offset_method,
"distance_method": distance_method,
"target_type": target_type_value,
}
if ellipse_params:
@@ -552,6 +442,11 @@ def process_shot(adc_val):
inner_data["ellipse_center_y"] = None
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)
# 数据上报后再画标注,不干扰检测阶段的原始画面
@@ -609,11 +504,6 @@ def process_shot(adc_val):
except Exception:
pass
# 物方采样标靶类型判断耗时(合并在上面采样块内,单独统计)
if _timing_on and bool(getattr(config, "TRIANGLE_SAMPLE_ENABLE", False)) and sample_target_type is not None:
logger.info(f"[采样] 标靶类型: {sample_target_type} 耗时: {_t_sample_ms:.2f}ms")
# 叠加信息:落点-圆心距离 / 相机-靶距离等
try:
import math as _math
@@ -661,6 +551,7 @@ def process_shot(adc_val):
laser_manager.flash_laser(config.FLASH_LASER_DURATION_MS)
# 保存图像(异步队列,与 main.py 一致)
_force_save = (dx is None and dy is None) and getattr(config, "SAVE_IMAGE_ON_FAILURE", False)
enqueue_save_shot(
result_img,
center,
@@ -670,8 +561,9 @@ def process_shot(adc_val):
(x, y),
distance_m,
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,
force_save=_force_save,
)
if logger:
+143 -1
View File
@@ -89,6 +89,29 @@ def _stage2_roi_crop_save_worker(
_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():
"""切换模型路径时可调用(通常不必)。"""
global _detector_by_path
@@ -175,6 +198,23 @@ def preload_yolo_detector(logger=None):
% (_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
@@ -206,8 +246,10 @@ def _det_obj_class_id(o):
if v is None:
continue
try:
if callable(v):
v = v()
return int(float(v))
except (TypeError, ValueError):
except (TypeError, ValueError, AttributeError):
continue
return None
@@ -242,6 +284,106 @@ def _normalize_objs(objs):
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):
"""把单个检测框转为全图坐标系下的 xyxy(半开区间语义与后续 clip 一致)。"""
x, y, w, h = float(o.x), float(o.y), float(o.w), float(o.h)
+295
View File
@@ -0,0 +1,295 @@
# -*- coding: utf-8 -*-
# Generated by the protocol buffer compiler. DO NOT EDIT!
# source: tcp_messages.proto
"""Generated protocol buffer code."""
from google.protobuf import descriptor as _descriptor
from google.protobuf import descriptor_pool as _descriptor_pool
from google.protobuf import message as _message
from google.protobuf import reflection as _reflection
from google.protobuf import symbol_database as _symbol_database
# @@protoc_insertion_point(imports)
_sym_db = _symbol_database.Default()
DESCRIPTOR = _descriptor_pool.Default().AddSerializedFile(b'\n\x12tcp_messages.proto\x12\x03tcp\"\x83\x01\n\x0cLoginRequest\x12\x11\n\tdevice_id\x18\x01 \x01(\t\x12\x10\n\x08password\x18\x02 \x01(\t\x12\x10\n\x08if_admin\x18\x03 \x01(\x08\x12\x0f\n\x07version\x18\x04 \x01(\t\x12\x0b\n\x03vol\x18\x05 \x01(\x01\x12\x0f\n\x07vol_per\x18\x06 \x01(\x01\x12\r\n\x05iccid\x18\x07 \x01(\t\"*\n\rLoginResponse\x12\x0b\n\x03\x63md\x18\x01 \x01(\r\x12\x0c\n\x04\x64\x61ta\x18\x02 \x01(\t\"4\n\tHeartbeat\x12\t\n\x01t\x18\x01 \x01(\x03\x12\x0b\n\x03vol\x18\x02 \x01(\x01\x12\x0f\n\x07vol_per\x18\x03 \x01(\x01\"&\n\tLogicBody\x12\x0b\n\x03\x63md\x18\x01 \x01(\r\x12\x0c\n\x04\x64\x61ta\x18\x02 \x01(\x0c\")\n\x0cResponseBody\x12\x0b\n\x03\x63md\x18\x01 \x01(\r\x12\x0c\n\x04\x64\x61ta\x18\x02 \x01(\x0c\"@\n\x0bMonitorBody\x12\x11\n\tdevice_id\x18\x01 \x01(\t\x12\x0c\n\x04\x64\x61ta\x18\x02 \x01(\x0c\x12\x10\n\x08msg_type\x18\x03 \x01(\r\",\n\x16MonitorDevicesResponse\x12\x12\n\ndevice_ids\x18\x01 \x03(\t\"9\n\x0bOtaFragment\x12\t\n\x01l\x18\x01 \x01(\x05\x12\t\n\x01\x64\x18\x02 \x01(\t\x12\t\n\x01t\x18\x03 \x01(\x05\x12\t\n\x01v\x18\x04 \x01(\t\"\xab\x02\n\tShootData\x12\x0f\n\x07shot_id\x18\x01 \x01(\t\x12\t\n\x01x\x18\x02 \x01(\x01\x12\t\n\x01y\x18\x03 \x01(\x01\x12\t\n\x01r\x18\x04 \x01(\x01\x12\t\n\x01\x64\x18\x05 \x01(\x01\x12\x0b\n\x03\x61\x64\x63\x18\x06 \x01(\x01\x12\x14\n\x0ctarget_class\x18\x07 \x01(\t\x12\x1f\n\x17target_class_confidence\x18\x08 \x01(\x01\x12\x0f\n\x07\x64_laser\x18\t \x01(\x01\x12\x17\n\x0f\x64_laser_quality\x18\n \x01(\x01\x12\t\n\x01m\x18\x0b \x01(\t\x12\x14\n\x0claser_method\x18\x0c \x01(\t\x12\x10\n\x08target_x\x18\r \x01(\x01\x12\x10\n\x08target_y\x18\x0e \x01(\x01\x12\x15\n\roffset_method\x18\x0f \x01(\t\x12\x17\n\x0f\x64istance_method\x18\x10 \x01(\t\"!\n\nShootEvent\x12\x13\n\x0bshoot_event\x18\x01 \x01(\t\"C\n\rBatteryReport\x12\x0f\n\x07\x62\x61ttery\x18\x01 \x01(\x01\x12\x0f\n\x07voltage\x18\x02 \x01(\x01\x12\x10\n\x08net_type\x18\x03 \x01(\t\"9\n\x11\x43\x65nterPointResult\x12\x0e\n\x06result\x18\x01 \x01(\t\x12\t\n\x01x\x18\x02 \x01(\x01\x12\t\n\x01y\x18\x03 \x01(\x01\"3\n\x0e\x43\x65nterPointSet\x12\x0b\n\x03\x63md\x18\x01 \x01(\r\x12\t\n\x01x\x18\x02 \x01(\x01\x12\t\n\x01y\x18\x03 \x01(\x01\"(\n\tOtaResult\x12\x0e\n\x06result\x18\x01 \x01(\t\x12\x0b\n\x03url\x18\x02 \x01(\t\"\x1f\n\rGenericResult\x12\x0e\n\x06result\x18\x01 \x01(\t\"&\n\x08IpReport\x12\x0e\n\x06result\x18\x01 \x01(\t\x12\n\n\x02ip\x18\x02 \x01(\t\"Z\n\x12ImageUploadCommand\x12\x12\n\nupload_url\x18\x01 \x01(\t\x12\r\n\x05token\x18\x02 \x01(\t\x12\x10\n\x08shoot_id\x18\x03 \x01(\t\x12\x0f\n\x07outlink\x18\x04 \x01(\t\"d\n\x10LogUploadCommand\x12\x12\n\nupload_url\x18\x01 \x01(\t\x12\r\n\x05token\x18\x02 \x01(\t\x12\x0b\n\x03key\x18\x03 \x01(\t\x12\x0f\n\x07outlink\x18\x04 \x01(\t\x12\x0f\n\x07\x61rchive\x18\x05 \x01(\t\"K\n\x0eOtaRequestData\x12\x0c\n\x04ssid\x18\x01 \x01(\t\x12\x10\n\x08password\x18\x02 \x01(\t\x12\x0b\n\x03url\x18\x03 \x01(\t\x12\x0c\n\x04mode\x18\x04 \x01(\t\"1\n\x0fWifiConnectData\x12\x0c\n\x04ssid\x18\x01 \x01(\t\x12\x10\n\x08password\x18\x02 \x01(\t\"_\n\x11ImageUploadResult\x12\x0e\n\x06result\x18\x01 \x01(\t\x12\x10\n\x08shoot_id\x18\x02 \x01(\t\x12\x0b\n\x03key\x18\x03 \x01(\t\x12\x0e\n\x06reason\x18\x04 \x01(\t\x12\x0b\n\x03via\x18\x05 \x01(\t\"v\n\x0fLogUploadResult\x12\x0e\n\x06result\x18\x01 \x01(\t\x12\x0e\n\x06reason\x18\x02 \x01(\t\x12\x10\n\x08\x66ilename\x18\x03 \x01(\t\x12\x13\n\x0bstatus_code\x18\x04 \x01(\x05\x12\x0c\n\x04ssid\x18\x05 \x01(\t\x12\x0e\n\x06\x64\x65tail\x18\x06 \x01(\t\"J\n\x14\x42\x61tteryQueryResponse\x12\x0f\n\x07\x62\x61ttery\x18\x01 \x01(\x01\x12\x0f\n\x07voltage\x18\x02 \x01(\x01\x12\x10\n\x08net_type\x18\x03 \x01(\t\"\'\n\x0fOta4gSubCodeReq\x12\t\n\x01l\x18\x01 \x01(\x05\x12\t\n\x01v\x18\x02 \x01(\t\"5\n\x10Ota4gSubCodeResp\x12\x0b\n\x03\x63md\x18\x01 \x01(\r\x12\t\n\x01l\x18\x02 \x01(\x05\x12\t\n\x01v\x18\x03 \x01(\t\"\x1e\n\x0fShutdownCommand\x12\x0b\n\x03\x63md\x18\x01 \x01(\r\" \n\x0c\x41utoShutdown\x12\x10\n\x08poweroff\x18\x01 \x01(\tBB\n\rcom.shoot.tcpZ1git.shelingxingqiu.com/shoot-tcp-server/proto;tcpb\x06proto3')
_LOGINREQUEST = DESCRIPTOR.message_types_by_name['LoginRequest']
_LOGINRESPONSE = DESCRIPTOR.message_types_by_name['LoginResponse']
_HEARTBEAT = DESCRIPTOR.message_types_by_name['Heartbeat']
_LOGICBODY = DESCRIPTOR.message_types_by_name['LogicBody']
_RESPONSEBODY = DESCRIPTOR.message_types_by_name['ResponseBody']
_MONITORBODY = DESCRIPTOR.message_types_by_name['MonitorBody']
_MONITORDEVICESRESPONSE = DESCRIPTOR.message_types_by_name['MonitorDevicesResponse']
_OTAFRAGMENT = DESCRIPTOR.message_types_by_name['OtaFragment']
_SHOOTDATA = DESCRIPTOR.message_types_by_name['ShootData']
_SHOOTEVENT = DESCRIPTOR.message_types_by_name['ShootEvent']
_BATTERYREPORT = DESCRIPTOR.message_types_by_name['BatteryReport']
_CENTERPOINTRESULT = DESCRIPTOR.message_types_by_name['CenterPointResult']
_CENTERPOINTSET = DESCRIPTOR.message_types_by_name['CenterPointSet']
_OTARESULT = DESCRIPTOR.message_types_by_name['OtaResult']
_GENERICRESULT = DESCRIPTOR.message_types_by_name['GenericResult']
_IPREPORT = DESCRIPTOR.message_types_by_name['IpReport']
_IMAGEUPLOADCOMMAND = DESCRIPTOR.message_types_by_name['ImageUploadCommand']
_LOGUPLOADCOMMAND = DESCRIPTOR.message_types_by_name['LogUploadCommand']
_OTAREQUESTDATA = DESCRIPTOR.message_types_by_name['OtaRequestData']
_WIFICONNECTDATA = DESCRIPTOR.message_types_by_name['WifiConnectData']
_IMAGEUPLOADRESULT = DESCRIPTOR.message_types_by_name['ImageUploadResult']
_LOGUPLOADRESULT = DESCRIPTOR.message_types_by_name['LogUploadResult']
_BATTERYQUERYRESPONSE = DESCRIPTOR.message_types_by_name['BatteryQueryResponse']
_OTA4GSUBCODEREQ = DESCRIPTOR.message_types_by_name['Ota4gSubCodeReq']
_OTA4GSUBCODERESP = DESCRIPTOR.message_types_by_name['Ota4gSubCodeResp']
_SHUTDOWNCOMMAND = DESCRIPTOR.message_types_by_name['ShutdownCommand']
_AUTOSHUTDOWN = DESCRIPTOR.message_types_by_name['AutoShutdown']
LoginRequest = _reflection.GeneratedProtocolMessageType('LoginRequest', (_message.Message,), {
'DESCRIPTOR' : _LOGINREQUEST,
'__module__' : 'tcp_messages_pb2'
# @@protoc_insertion_point(class_scope:tcp.LoginRequest)
})
_sym_db.RegisterMessage(LoginRequest)
LoginResponse = _reflection.GeneratedProtocolMessageType('LoginResponse', (_message.Message,), {
'DESCRIPTOR' : _LOGINRESPONSE,
'__module__' : 'tcp_messages_pb2'
# @@protoc_insertion_point(class_scope:tcp.LoginResponse)
})
_sym_db.RegisterMessage(LoginResponse)
Heartbeat = _reflection.GeneratedProtocolMessageType('Heartbeat', (_message.Message,), {
'DESCRIPTOR' : _HEARTBEAT,
'__module__' : 'tcp_messages_pb2'
# @@protoc_insertion_point(class_scope:tcp.Heartbeat)
})
_sym_db.RegisterMessage(Heartbeat)
LogicBody = _reflection.GeneratedProtocolMessageType('LogicBody', (_message.Message,), {
'DESCRIPTOR' : _LOGICBODY,
'__module__' : 'tcp_messages_pb2'
# @@protoc_insertion_point(class_scope:tcp.LogicBody)
})
_sym_db.RegisterMessage(LogicBody)
ResponseBody = _reflection.GeneratedProtocolMessageType('ResponseBody', (_message.Message,), {
'DESCRIPTOR' : _RESPONSEBODY,
'__module__' : 'tcp_messages_pb2'
# @@protoc_insertion_point(class_scope:tcp.ResponseBody)
})
_sym_db.RegisterMessage(ResponseBody)
MonitorBody = _reflection.GeneratedProtocolMessageType('MonitorBody', (_message.Message,), {
'DESCRIPTOR' : _MONITORBODY,
'__module__' : 'tcp_messages_pb2'
# @@protoc_insertion_point(class_scope:tcp.MonitorBody)
})
_sym_db.RegisterMessage(MonitorBody)
MonitorDevicesResponse = _reflection.GeneratedProtocolMessageType('MonitorDevicesResponse', (_message.Message,), {
'DESCRIPTOR' : _MONITORDEVICESRESPONSE,
'__module__' : 'tcp_messages_pb2'
# @@protoc_insertion_point(class_scope:tcp.MonitorDevicesResponse)
})
_sym_db.RegisterMessage(MonitorDevicesResponse)
OtaFragment = _reflection.GeneratedProtocolMessageType('OtaFragment', (_message.Message,), {
'DESCRIPTOR' : _OTAFRAGMENT,
'__module__' : 'tcp_messages_pb2'
# @@protoc_insertion_point(class_scope:tcp.OtaFragment)
})
_sym_db.RegisterMessage(OtaFragment)
ShootData = _reflection.GeneratedProtocolMessageType('ShootData', (_message.Message,), {
'DESCRIPTOR' : _SHOOTDATA,
'__module__' : 'tcp_messages_pb2'
# @@protoc_insertion_point(class_scope:tcp.ShootData)
})
_sym_db.RegisterMessage(ShootData)
ShootEvent = _reflection.GeneratedProtocolMessageType('ShootEvent', (_message.Message,), {
'DESCRIPTOR' : _SHOOTEVENT,
'__module__' : 'tcp_messages_pb2'
# @@protoc_insertion_point(class_scope:tcp.ShootEvent)
})
_sym_db.RegisterMessage(ShootEvent)
BatteryReport = _reflection.GeneratedProtocolMessageType('BatteryReport', (_message.Message,), {
'DESCRIPTOR' : _BATTERYREPORT,
'__module__' : 'tcp_messages_pb2'
# @@protoc_insertion_point(class_scope:tcp.BatteryReport)
})
_sym_db.RegisterMessage(BatteryReport)
CenterPointResult = _reflection.GeneratedProtocolMessageType('CenterPointResult', (_message.Message,), {
'DESCRIPTOR' : _CENTERPOINTRESULT,
'__module__' : 'tcp_messages_pb2'
# @@protoc_insertion_point(class_scope:tcp.CenterPointResult)
})
_sym_db.RegisterMessage(CenterPointResult)
CenterPointSet = _reflection.GeneratedProtocolMessageType('CenterPointSet', (_message.Message,), {
'DESCRIPTOR' : _CENTERPOINTSET,
'__module__' : 'tcp_messages_pb2'
# @@protoc_insertion_point(class_scope:tcp.CenterPointSet)
})
_sym_db.RegisterMessage(CenterPointSet)
OtaResult = _reflection.GeneratedProtocolMessageType('OtaResult', (_message.Message,), {
'DESCRIPTOR' : _OTARESULT,
'__module__' : 'tcp_messages_pb2'
# @@protoc_insertion_point(class_scope:tcp.OtaResult)
})
_sym_db.RegisterMessage(OtaResult)
GenericResult = _reflection.GeneratedProtocolMessageType('GenericResult', (_message.Message,), {
'DESCRIPTOR' : _GENERICRESULT,
'__module__' : 'tcp_messages_pb2'
# @@protoc_insertion_point(class_scope:tcp.GenericResult)
})
_sym_db.RegisterMessage(GenericResult)
IpReport = _reflection.GeneratedProtocolMessageType('IpReport', (_message.Message,), {
'DESCRIPTOR' : _IPREPORT,
'__module__' : 'tcp_messages_pb2'
# @@protoc_insertion_point(class_scope:tcp.IpReport)
})
_sym_db.RegisterMessage(IpReport)
ImageUploadCommand = _reflection.GeneratedProtocolMessageType('ImageUploadCommand', (_message.Message,), {
'DESCRIPTOR' : _IMAGEUPLOADCOMMAND,
'__module__' : 'tcp_messages_pb2'
# @@protoc_insertion_point(class_scope:tcp.ImageUploadCommand)
})
_sym_db.RegisterMessage(ImageUploadCommand)
LogUploadCommand = _reflection.GeneratedProtocolMessageType('LogUploadCommand', (_message.Message,), {
'DESCRIPTOR' : _LOGUPLOADCOMMAND,
'__module__' : 'tcp_messages_pb2'
# @@protoc_insertion_point(class_scope:tcp.LogUploadCommand)
})
_sym_db.RegisterMessage(LogUploadCommand)
OtaRequestData = _reflection.GeneratedProtocolMessageType('OtaRequestData', (_message.Message,), {
'DESCRIPTOR' : _OTAREQUESTDATA,
'__module__' : 'tcp_messages_pb2'
# @@protoc_insertion_point(class_scope:tcp.OtaRequestData)
})
_sym_db.RegisterMessage(OtaRequestData)
WifiConnectData = _reflection.GeneratedProtocolMessageType('WifiConnectData', (_message.Message,), {
'DESCRIPTOR' : _WIFICONNECTDATA,
'__module__' : 'tcp_messages_pb2'
# @@protoc_insertion_point(class_scope:tcp.WifiConnectData)
})
_sym_db.RegisterMessage(WifiConnectData)
ImageUploadResult = _reflection.GeneratedProtocolMessageType('ImageUploadResult', (_message.Message,), {
'DESCRIPTOR' : _IMAGEUPLOADRESULT,
'__module__' : 'tcp_messages_pb2'
# @@protoc_insertion_point(class_scope:tcp.ImageUploadResult)
})
_sym_db.RegisterMessage(ImageUploadResult)
LogUploadResult = _reflection.GeneratedProtocolMessageType('LogUploadResult', (_message.Message,), {
'DESCRIPTOR' : _LOGUPLOADRESULT,
'__module__' : 'tcp_messages_pb2'
# @@protoc_insertion_point(class_scope:tcp.LogUploadResult)
})
_sym_db.RegisterMessage(LogUploadResult)
BatteryQueryResponse = _reflection.GeneratedProtocolMessageType('BatteryQueryResponse', (_message.Message,), {
'DESCRIPTOR' : _BATTERYQUERYRESPONSE,
'__module__' : 'tcp_messages_pb2'
# @@protoc_insertion_point(class_scope:tcp.BatteryQueryResponse)
})
_sym_db.RegisterMessage(BatteryQueryResponse)
Ota4gSubCodeReq = _reflection.GeneratedProtocolMessageType('Ota4gSubCodeReq', (_message.Message,), {
'DESCRIPTOR' : _OTA4GSUBCODEREQ,
'__module__' : 'tcp_messages_pb2'
# @@protoc_insertion_point(class_scope:tcp.Ota4gSubCodeReq)
})
_sym_db.RegisterMessage(Ota4gSubCodeReq)
Ota4gSubCodeResp = _reflection.GeneratedProtocolMessageType('Ota4gSubCodeResp', (_message.Message,), {
'DESCRIPTOR' : _OTA4GSUBCODERESP,
'__module__' : 'tcp_messages_pb2'
# @@protoc_insertion_point(class_scope:tcp.Ota4gSubCodeResp)
})
_sym_db.RegisterMessage(Ota4gSubCodeResp)
ShutdownCommand = _reflection.GeneratedProtocolMessageType('ShutdownCommand', (_message.Message,), {
'DESCRIPTOR' : _SHUTDOWNCOMMAND,
'__module__' : 'tcp_messages_pb2'
# @@protoc_insertion_point(class_scope:tcp.ShutdownCommand)
})
_sym_db.RegisterMessage(ShutdownCommand)
AutoShutdown = _reflection.GeneratedProtocolMessageType('AutoShutdown', (_message.Message,), {
'DESCRIPTOR' : _AUTOSHUTDOWN,
'__module__' : 'tcp_messages_pb2'
# @@protoc_insertion_point(class_scope:tcp.AutoShutdown)
})
_sym_db.RegisterMessage(AutoShutdown)
if _descriptor._USE_C_DESCRIPTORS == False:
DESCRIPTOR._options = None
DESCRIPTOR._serialized_options = b'\n\rcom.shoot.tcpZ1git.shelingxingqiu.com/shoot-tcp-server/proto;tcp'
_LOGINREQUEST._serialized_start=28
_LOGINREQUEST._serialized_end=159
_LOGINRESPONSE._serialized_start=161
_LOGINRESPONSE._serialized_end=203
_HEARTBEAT._serialized_start=205
_HEARTBEAT._serialized_end=257
_LOGICBODY._serialized_start=259
_LOGICBODY._serialized_end=297
_RESPONSEBODY._serialized_start=299
_RESPONSEBODY._serialized_end=340
_MONITORBODY._serialized_start=342
_MONITORBODY._serialized_end=406
_MONITORDEVICESRESPONSE._serialized_start=408
_MONITORDEVICESRESPONSE._serialized_end=452
_OTAFRAGMENT._serialized_start=454
_OTAFRAGMENT._serialized_end=511
_SHOOTDATA._serialized_start=514
_SHOOTDATA._serialized_end=813
_SHOOTEVENT._serialized_start=815
_SHOOTEVENT._serialized_end=848
_BATTERYREPORT._serialized_start=850
_BATTERYREPORT._serialized_end=917
_CENTERPOINTRESULT._serialized_start=919
_CENTERPOINTRESULT._serialized_end=976
_CENTERPOINTSET._serialized_start=978
_CENTERPOINTSET._serialized_end=1029
_OTARESULT._serialized_start=1031
_OTARESULT._serialized_end=1071
_GENERICRESULT._serialized_start=1073
_GENERICRESULT._serialized_end=1104
_IPREPORT._serialized_start=1106
_IPREPORT._serialized_end=1144
_IMAGEUPLOADCOMMAND._serialized_start=1146
_IMAGEUPLOADCOMMAND._serialized_end=1236
_LOGUPLOADCOMMAND._serialized_start=1238
_LOGUPLOADCOMMAND._serialized_end=1338
_OTAREQUESTDATA._serialized_start=1340
_OTAREQUESTDATA._serialized_end=1415
_WIFICONNECTDATA._serialized_start=1417
_WIFICONNECTDATA._serialized_end=1466
_IMAGEUPLOADRESULT._serialized_start=1468
_IMAGEUPLOADRESULT._serialized_end=1563
_LOGUPLOADRESULT._serialized_start=1565
_LOGUPLOADRESULT._serialized_end=1683
_BATTERYQUERYRESPONSE._serialized_start=1685
_BATTERYQUERYRESPONSE._serialized_end=1759
_OTA4GSUBCODEREQ._serialized_start=1761
_OTA4GSUBCODEREQ._serialized_end=1800
_OTA4GSUBCODERESP._serialized_start=1802
_OTA4GSUBCODERESP._serialized_end=1855
_SHUTDOWNCOMMAND._serialized_start=1857
_SHUTDOWNCOMMAND._serialized_end=1887
_AUTOSHUTDOWN._serialized_start=1889
_AUTOSHUTDOWN._serialized_end=1921
# @@protoc_insertion_point(module_scope)
Binary file not shown.
+27
View File
@@ -0,0 +1,27 @@
"""Run independently and keep A24 at a high logic level."""
from maix import app, gpio, pinmap, time
PIN = "A17"
GPIO_NAME = "GPIOA17"
def main():
pinmap.set_pin_function(PIN, GPIO_NAME)
output = gpio.GPIO(GPIO_NAME, gpio.Mode.OUT)
output.value(1)
print(f"{PIN} is HIGH. Stop the script to set it LOW.")
try:
while not app.need_exit():
# Refresh the output in case another component changes its state.
output.value(1)
time.sleep_ms(100)
finally:
output.value(0)
print(f"{PIN} is LOW.")
if __name__ == "__main__":
main()
Binary file not shown.
-403
View File
@@ -1,403 +0,0 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
实时摄像头预览:叠加与射箭存图相同的算法标注(YOLO ROI、三角/圆心、激光十字等),默认不写盘。
在 MaixCAM 上从项目根目录运行:
python3 test/test_algo_preview_live.py
python3 test/test_algo_preview_live.py --interval 1.5
python3 test/test_algo_preview_live.py --every-frame
说明:
- 完整算法走 shoot_manager.analyze_shot(与 process_shot 一致,含 YOLO + 三角/圆心)。
- 画面标注对齐 process_shot 存图前绘制 + vision._draw_yolo_roi_on_rgb_numpy / 圆心存图线。
- 预览模式会关闭 Stage2 裁切 JPEG 落盘,避免写满 /root/phot。
"""
from __future__ import annotations
import argparse
import math
import os
import sys
import threading
import time
_ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
if _ROOT not in sys.path:
sys.path.insert(0, _ROOT)
import cv2
import numpy as np
from maix import image, time as maix_time
import config
from camera_manager import camera_manager
from laser_manager import laser_manager
from shoot_manager import analyze_shot, preload_triangle_calib
from target_roi_yolo import preload_yolo_detector
from vision import _draw_yolo_roi_on_rgb_numpy
def _copy_maix_frame(frame):
"""相机下一帧可能复用缓冲区,异步分析前先复制。"""
img_cv = image.image2cv(frame, False, False)
return image.cv2image(np.ascontiguousarray(img_cv), False, False)
def _patch_preview_config():
"""预览不写调试 JPEG,避免刷屏占存储。"""
config.TRIANGLE_BLACK_YOLO_SAVE_ROI_CROP = False
config.TRIANGLE_SAVE_DEBUG_IMAGE = False
def _annotate_like_saved_shot(analysis: dict):
"""
将 analyze_shot 结果绘制成与 process_shot -> enqueue_save_shot 存盘前一致的 Maix 图。
"""
result_img = analysis.get("result_img")
if result_img is None:
return None
center = analysis.get("center")
radius = analysis.get("radius")
method = analysis.get("method")
ellipse_params = analysis.get("ellipse_params")
laser_point = analysis.get("laser_point")
dx = analysis.get("dx")
dy = analysis.get("dy")
distance_m = analysis.get("distance_m")
offset_method = analysis.get("offset_method", "")
distance_method = analysis.get("distance_method", "")
tri_markers = analysis.get("tri_markers") or []
tri_markers_completed = analysis.get("tri_markers_completed") or []
tri_homography = analysis.get("tri_homography")
yolo_roi_xyxy = analysis.get("yolo_roi_xyxy")
if laser_point is None:
return result_img
x, y = laser_point
draw_yolo_roi = (
yolo_roi_xyxy is not None
and getattr(config, "TRIANGLE_YOLO_DRAW_ROI_ON_SHOT", True)
)
if tri_markers:
img_cv = image.image2cv(result_img, False, False).copy()
if draw_yolo_roi:
_draw_yolo_roi_on_rgb_numpy(img_cv, yolo_roi_xyxy)
for m in tri_markers:
corners = np.array(m["corners"], dtype=np.int32)
cv2.polylines(img_cv, [corners], True, (0, 255, 0), 2)
cx, cy = int(m["center"][0]), int(m["center"][1])
cv2.circle(img_cv, (cx, cy), 4, (0, 0, 255), -1)
cv2.putText(
img_cv,
f"T{m['id']}",
(cx - 18, cy - 12),
cv2.FONT_HERSHEY_SIMPLEX,
0.55,
(0, 255, 0),
1,
)
for m in tri_markers_completed:
if not m.get("is_virtual"):
continue
cx, cy = int(m["center"][0]), int(m["center"][1])
cv2.circle(img_cv, (cx, cy), 6, (255, 0, 255), 2)
cv2.putText(
img_cv,
f"VT{m['id']}",
(cx - 22, cy - 12),
cv2.FONT_HERSHEY_SIMPLEX,
0.55,
(255, 0, 255),
1,
)
if tri_homography is not None:
try:
H_inv = np.linalg.inv(tri_homography)
c_img = cv2.perspectiveTransform(
np.array([[[0.0, 0.0]]], dtype=np.float32), H_inv
)[0][0]
ocx, ocy = int(c_img[0]), int(c_img[1])
cv2.circle(img_cv, (ocx, ocy), 5, (0, 0, 255), -1)
cv2.circle(img_cv, (ocx, ocy), 9, (0, 0, 255), 1)
except Exception:
pass
lines = []
if dx is not None and dy is not None:
r_cm = math.hypot(float(dx), float(dy))
lines.append(f"offset=({float(dx):.2f},{float(dy):.2f})cm |r|={r_cm:.2f}cm")
if distance_m is not None:
lines.append(f"cam_dist={float(distance_m):.2f}m ({distance_method})")
if method:
lines.append(f"method={method} ({offset_method})")
y0 = 22
for i, t in enumerate(lines):
cv2.putText(
img_cv,
t,
(10, y0 + i * 18),
cv2.FONT_HERSHEY_SIMPLEX,
0.5,
(0, 255, 0),
1,
)
out = image.cv2image(img_cv, False, False)
else:
img_cv = image.image2cv(result_img, False, False).copy()
if draw_yolo_roi:
_draw_yolo_roi_on_rgb_numpy(img_cv, yolo_roi_xyxy)
if center and radius:
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])
cv2.ellipse(
img_cv,
(cx_ell, cy_ell),
(int(width / 2), int(height / 2)),
angle,
0,
360,
(0, 255, 0),
2,
)
cv2.circle(img_cv, (cx_ell, cy_ell), 3, (255, 0, 0), -1)
minor_length = min(width, height) / 2
minor_angle = angle + 90 if width >= height else angle
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 = (int(cx_ell - dx_minor), int(cy_ell - dy_minor))
pt2 = (int(cx_ell + dx_minor), int(cy_ell + dy_minor))
cv2.line(img_cv, pt1, pt2, (0, 0, 255), 2)
else:
cv2.circle(img_cv, (int(cx), int(cy)), int(radius), (0, 0, 255), 2)
cv2.circle(img_cv, (int(cx), int(cy)), 2, (0, 0, 255), -1)
cv2.line(img_cv, (int(x), int(y)), (int(cx), int(cy)), (255, 255, 0), 1)
lines = []
if dx is not None and dy is not None:
lines.append(f"offset=({float(dx):.2f},{float(dy):.2f})cm")
if distance_m is not None:
lines.append(f"dist={float(distance_m):.2f}m ({distance_method})")
if method:
lines.append(f"method={method}")
for i, t in enumerate(lines):
cv2.putText(
img_cv,
t,
(10, 22 + i * 18),
cv2.FONT_HERSHEY_SIMPLEX,
0.5,
(0, 255, 0),
1,
)
out = image.cv2image(img_cv, False, False)
lc = image.Color(config.LASER_COLOR[0], config.LASER_COLOR[1], config.LASER_COLOR[2])
out.draw_line(
int(x - config.LASER_LENGTH),
int(y),
int(x + config.LASER_LENGTH),
int(y),
lc,
config.LASER_THICKNESS,
)
out.draw_line(
int(x),
int(y - config.LASER_LENGTH),
int(x),
int(y + config.LASER_LENGTH),
lc,
config.LASER_THICKNESS,
)
out.draw_circle(int(x), int(y), 1, lc, config.LASER_THICKNESS)
return out
class _AlgoWorker:
def __init__(self):
self._lock = threading.Lock()
self._busy = False
self._latest_preview = None
self._latest_meta = ""
self._last_ms = 0.0
@property
def busy(self):
with self._lock:
return self._busy
@property
def last_ms(self):
with self._lock:
return self._last_ms
def get_preview(self):
with self._lock:
return self._latest_preview, self._latest_meta
def run_async(self, frame):
with self._lock:
if self._busy:
return False
self._busy = True
def _job():
t0 = time.perf_counter()
meta = ""
preview = None
try:
analysis = analyze_shot(frame)
if not analysis.get("success"):
reason = analysis.get("reason", "unknown")
meta = f"fail:{reason}"
else:
preview = _annotate_like_saved_shot(analysis)
dx, dy = analysis.get("dx"), analysis.get("dy")
method = analysis.get("method") or "?"
if dx is not None and dy is not None:
meta = f"ok {method} ({dx:.2f},{dy:.2f})cm"
else:
meta = f"ok {method} no_offset"
except Exception as e:
meta = f"err:{e}"
elapsed = (time.perf_counter() - t0) * 1000.0
with self._lock:
self._latest_preview = preview
self._latest_meta = f"{meta} {elapsed:.0f}ms"
self._last_ms = elapsed
self._busy = False
threading.Thread(target=_job, daemon=True).start()
return True
def _draw_status(frame, lines, color=None):
if color is None:
color = image.COLOR_YELLOW
y = 4
for line in lines:
frame.draw_string(4, y, line, color=color)
y += 16
def _save_preview_jpeg(maix_img, out_dir):
os.makedirs(out_dir, exist_ok=True)
fn = os.path.join(out_dir, f"preview_{int(time.time() * 1000)}.jpg")
maix_img.save(fn)
return fn
def main():
parser = argparse.ArgumentParser(description="实时预览射箭算法存图效果")
parser.add_argument(
"--interval",
type=float,
default=2.0,
help="两次完整 analyze_shot 的最小间隔(秒);--every-frame 时忽略",
)
parser.add_argument(
"--every-frame",
action="store_true",
help="每帧都触发算法(很慢,仅调试用)",
)
parser.add_argument(
"--width",
type=int,
default=getattr(config, "CAMERA_WIDTH", 640),
)
parser.add_argument(
"--height",
type=int,
default=getattr(config, "CAMERA_HEIGHT", 480),
)
parser.add_argument(
"--save-dir",
default=config.PHOTO_DIR,
help="按板子按键无;用 --save-every N 每 N 次成功分析存一张",
)
parser.add_argument(
"--save-every",
type=int,
default=0,
help="每成功分析 N 次自动存一张到 --save-dir(0=不自动存)",
)
args = parser.parse_args()
_patch_preview_config()
print("[INFO] 预览模式:已关闭 TRIANGLE_BLACK_YOLO_SAVE_ROI_CROP / TRIANGLE_SAVE_DEBUG_IMAGE")
laser_manager.load_laser_point()
preload_triangle_calib()
if getattr(config, "TRIANGLE_YOLO_PRELOAD_ON_BOOT", False) or getattr(
config, "TRIANGLE_BLACK_YOLO_PRELOAD_ON_BOOT", False
):
print("[INFO] 预加载 YOLO …")
preload_yolo_detector()
camera_manager.init_camera(args.width, args.height)
camera_manager.init_display()
worker = _AlgoWorker()
interval_s = 0.0 if args.every_frame else max(0.3, float(args.interval))
last_trigger = 0.0
ok_count = 0
frame_idx = 0
print(
f"[INFO] 摄像头 {args.width}x{args.height} "
f"interval={'每帧' if args.every_frame else f'{interval_s}s'}"
)
print("[INFO] 退出:Ctrl+C")
try:
while True:
frame = camera_manager.read_frame()
frame_idx += 1
now = time.perf_counter()
due = args.every_frame or (now - last_trigger >= interval_s)
if due and not worker.busy:
last_trigger = now
worker.run_async(_copy_maix_frame(frame))
preview, meta = worker.get_preview()
if preview is not None:
show_img = preview
status = [f"#{frame_idx}", meta]
if args.save_every > 0 and meta.startswith("ok"):
ok_count += 1
if ok_count % args.save_every == 0:
try:
fn = _save_preview_jpeg(preview, args.save_dir)
status.append(f"saved:{fn}")
except Exception as e:
status.append(f"save_err:{e}")
else:
show_img = frame
if worker.busy:
status = [f"#{frame_idx}", "analyzing…"]
else:
status = [f"#{frame_idx}", "waiting…"]
_draw_status(show_img, status)
camera_manager.show(show_img)
maix_time.sleep_ms(1)
except KeyboardInterrupt:
print("[INFO] 已退出")
if __name__ == "__main__":
main()
+330
View File
@@ -0,0 +1,330 @@
#!/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
import math
# ==================== 全局配置 (与 test_main.py 保持一致) ====================
REAL_RADIUS_CM = 20 # 靶心实际半径(厘米)
def detect_circle_v3(frame, laser_point=None, img_cv=None):
"""检测图像中的靶心(优先清晰轮廓,其次黄色区域)- 返回椭圆参数版本
增加红色圆圈检测,验证黄色圆圈是否为真正的靶心
如果提供 laser_point,会选择最接近激光点的目标
优化:
1. 缩图到 MAX_DET_DIM 后再做 HSV/形态学,最长边 640->320 可获得 ~4x 加速
2. 红色掩码在黄色轮廓循环外只计算一次,避免 N 次重复计算
3. img_cv 可由外部传入(与其他线程共享转换结果),为 None 时自动转换
Args:
frame: 图像帧(img_cv 为 None 时使用)
laser_point: 激光点坐标 (x, y),用于多目标场景下的目标选择
img_cv: 已转换的 numpy BGR/RGB 图像;不为 None 时跳过 image2cv 转换
Returns:
(result_img, best_center, best_radius, method, best_radius1, ellipse_params)
"""
if img_cv is None:
img_cv = image.image2cv(frame, False, False)
from datetime import datetime
print(f"[detect_circle_v3] begin {datetime.now()}")
# -- 1. 缩图加速(与三角形路径保持一致)
h_orig, w_orig = img_cv.shape[:2]
MAX_DET_DIM = 480
long_side = max(h_orig, w_orig)
if long_side > MAX_DET_DIM:
det_scale = MAX_DET_DIM / long_side
img_det = cv2.resize(img_cv, (int(w_orig * det_scale), int(h_orig * det_scale)),
interpolation=cv2.INTER_LINEAR)
inv_scale = 1.0 / det_scale # 检测坐标 -> 原始坐标的倍率
else:
img_det = img_cv
inv_scale = 1.0
# 激光点映射到检测分辨率
lp_det = None
if laser_point is not None:
lp_det = (laser_point[0] / inv_scale, laser_point[1] / inv_scale)
best_center = best_radius = best_radius1 = method = None
ellipse_params = None
print(f"[detect_circle_v3] step 1 fin {datetime.now()}")
# -- 2. HSV + 黄色掩码
hsv = cv2.cvtColor(img_det, 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))
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)
print(f"[detect_circle_v3] step 2 fin {datetime.now()}")
# -- 3. 红色掩码:在循环外只算一次
mask_red = cv2.bitwise_or(
cv2.inRange(hsv, np.array([0, 50, 40]), np.array([10, 255, 255])),
cv2.inRange(hsv, np.array([170, 50, 40]), np.array([180, 255, 255])),
)
kernel_red = cv2.getStructuringElement(cv2.MORPH_ELLIPSE, (5, 5))
mask_red = cv2.morphologyEx(mask_red, cv2.MORPH_CLOSE, kernel_red)
contours_red, _ = cv2.findContours(mask_red, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE)
# 预先把红色轮廓筛选成 (center, radius) 列表,后续直接查表
red_candidates = []
for cnt_r in contours_red:
ar = cv2.contourArea(cnt_r)
if ar <= 10:
continue
pr = cv2.arcLength(cnt_r, True)
if pr <= 0 or (4 * np.pi * ar) / (pr * pr) <= 0.3:
continue
if len(cnt_r) >= 5:
(xr, yr), (wr, hr), _ = cv2.fitEllipse(cnt_r)
red_candidates.append({"center": (int(xr), int(yr)), "radius": int(min(wr, hr) / 2)})
else:
(xr, yr), rr = cv2.minEnclosingCircle(cnt_r)
red_candidates.append({"center": (int(xr), int(yr)), "radius": int(rr)})
print(f"[detect_circle_v3] step 3 fin {datetime.now()}")
# -- 4. 黄色轮廓循环(复用上面的红色候选列表)
contours_yellow, _ = cv2.findContours(mask_yellow, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE)
valid_targets = []
for cnt_yellow in contours_yellow:
area = cv2.contourArea(cnt_yellow)
if area <= 15:
continue
perimeter = cv2.arcLength(cnt_yellow, True)
if perimeter <= 0:
continue
circularity = (4 * np.pi * area) / (perimeter * perimeter)
if circularity <= 0.5:
continue
print(f"[target] -> 面积:{area:.1f}, 圆度:{circularity:.2f}")
if len(cnt_yellow) >= 5:
(x, y), (width, height), angle = cv2.fitEllipse(cnt_yellow)
yellow_ellipse = ((x, y), (width, height), angle)
yellow_center = (int(x), int(y))
yellow_radius = int(min(width, height) / 2)
else:
(x, y), radius = cv2.minEnclosingCircle(cnt_yellow)
yellow_center = (int(x), int(y))
yellow_radius = int(radius)
yellow_ellipse = None
# 在预筛好的红色候选中匹配
matched = False
for rc in red_candidates:
ddx = yellow_center[0] - rc["center"][0]
ddy = yellow_center[1] - rc["center"][1]
dist_centers = math.hypot(ddx, ddy)
if dist_centers < yellow_radius * 1.5 and rc["radius"] > yellow_radius * 0.7:
print(f"[target] -> 找到匹配的红圈: 黄心({yellow_center}), "
f"红心({rc['center']}), 距离:{dist_centers:.1f}, "
f"黄半径:{yellow_radius}, 红半径:{rc['radius']}")
valid_targets.append({
"center": yellow_center,
"radius": yellow_radius,
"ellipse": yellow_ellipse,
"area": area,
})
matched = True
break
if not matched :
print("Debug -> 未找到匹配的红色圆圈,可能是误识别")
print(f"[detect_circle_v3] step 4 fin {datetime.now()}")
# -- 5. 选最佳目标,坐标还原到原始分辨率
if valid_targets:
if lp_det:
best_target = min(valid_targets,
key=lambda t: (t["center"][0] - lp_det[0]) ** 2
+ (t["center"][1] - lp_det[1]) ** 2)
method = "v3_ellipse_red_validated_laser_selected"
else:
best_target = max(valid_targets, key=lambda t: t["area"])
method = "v3_ellipse_red_validated"
bc = best_target["center"]
br = best_target["radius"]
be = best_target["ellipse"]
if inv_scale != 1.0:
best_center = (int(bc[0] * inv_scale), int(bc[1] * inv_scale))
best_radius = int(br * inv_scale)
if be is not None:
(ex, ey), (ew, eh), ea = be
be = ((ex * inv_scale, ey * inv_scale),
(ew * inv_scale, eh * inv_scale), ea)
else:
best_center = bc
best_radius = br
ellipse_params = be
best_radius1 = best_radius * 5
result_img = image.cv2image(img_cv, False, False)
print(f"[detect_circle_v3] step 5 fin {datetime.now()}")
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_v3 函数
print("[INFO] 正在调用 detect_circle_v3 进行检测...")
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. 保存结果图片
base, ext = os.path.splitext(image_path)
output_path = f"{base}_result{ext}"
try:
result_img.save(output_path, quality=100)
print(f"[SUCCESS] 结果已保存至: {output_path}")
except Exception as e:
print(f"[ERROR] 保存图片失败: {e}")
if __name__ == "__main__":
# ================= 配置区域 =================
# 1. 设置要测试的图片路径
# 建议将图片放在与脚本同级目录,或者使用绝对路径
TARGET_IMAGE = "/root/phot/None_314_258_0_0041.bmp"
TARGET_DIR = "/root/phot" # 修改为你想要读取的目录路径
# 支持的图片格式
IMAGE_EXTENSIONS = ['.jpg', '.jpeg', '.png', '.bmp']
# ================= 执行区域 =================
if 'TARGET_DIR' in locals():
# 读取目录下所有图片文件,过滤掉 _result.jpg 后缀的文件
image_files = []
if os.path.exists(TARGET_DIR) and os.path.isdir(TARGET_DIR):
for filename in os.listdir(TARGET_DIR):
# 检查文件扩展名
if any(filename.lower().endswith(ext) for ext in IMAGE_EXTENSIONS):
# 过滤掉 _result.jpg 后缀的文件
if not filename.endswith('_result.jpg'):
filepath = os.path.join(TARGET_DIR, filename)
if os.path.isfile(filepath):
image_files.append(filepath)
# 按文件名排序(可选)
image_files.sort()
print(f"[INFO] 在目录 {TARGET_DIR} 中找到 {len(image_files)} 张图片")
# 处理每张图片
for img_path in image_files:
print(f"\n{'=' * 10} 开始处理: {img_path} {'=' * 10}")
run_offline_test(img_path)
else:
print(f"[ERROR] 目录不存在或不是有效目录: {TARGET_DIR}")
else:
run_offline_test(TARGET_IMAGE)
+635
View File
@@ -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)
+62
View File
@@ -0,0 +1,62 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""Interactive GPIO test for physical pin A14."""
import sys
from maix import gpio, pinmap
PIN = "A14"
GPIO_NAME = "GPIOA14"
def set_level(output, command):
if command == "1":
output.value(1)
print("A14 = HIGH, laser OFF")
return True
if command == "0":
output.value(0)
print("A14 = LOW, laser ON")
return True
return False
def main():
pinmap.set_pin_function(PIN, GPIO_NAME)
output = gpio.GPIO(GPIO_NAME, gpio.Mode.OUT)
# One-shot mode for SSH/serial shells: python3 test_gpio_a14.py 1|0
if len(sys.argv) > 1:
command = sys.argv[1].strip()
if not set_level(output, command):
print("Invalid argument. Use 1 or 0.")
return
return
output.value(1)
print("A14 laser test: input 0 for ON, 1 for OFF, q to quit.")
try:
while True:
try:
command = input("A14> ").strip().lower()
except EOFError:
print("This runner has no stdin. Run from an SSH/serial shell with argument 1 or 0.")
return
if set_level(output, command):
continue
elif command in ("q", "quit", "exit"):
break
elif command:
print("Invalid input. Use 1, 0, or q.")
except KeyboardInterrupt:
print()
finally:
output.value(1)
print("A14 = HIGH, laser OFF, test stopped.")
if __name__ == "__main__":
main()
-541
View File
@@ -1,541 +0,0 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
激光中心点检测单元测试(单文件,无项目依赖)
直接使用 maix 标准库,实现红色激光点坐标检测
运行方式:
python3 test/test_laser_center_point.py
Ctrl+C 退出,按 s 保存截图
"""
from maix import camera, display, image, time, app, uart, pinmap
import os
import struct
import select
_USE_CV = False
try:
import cv2
import numpy as np
_USE_CV = True
except ImportError:
pass
WIDTH = 640
HEIGHT = 480
THRESHOLD = 140
SEARCH_RADIUS = 50
def read_key_ev():
"""非阻塞读取 /dev/input/event0 按键(返回 key_code 或 -1"""
try:
r, _, _ = select.select([_key_fd], [], [], 0)
if r:
event = _key_fd.read(16)
if len(event) == 16:
_, _, etype, code, value = struct.unpack("IIHHI", event)
if etype == 1 and value == 1:
return code
except Exception:
pass
return -1
def find_ellipse(img_cv, cx, cy, roi_r, th):
x1 = max(0, cx - roi_r)
x2 = min(WIDTH, cx + roi_r)
y1 = max(0, cy - roi_r)
y2 = min(HEIGHT, cy + roi_r)
roi = img_cv[y1:y2, x1:x2]
if roi.size == 0:
return None
r = roi[:, :, 0].astype(np.int32)
g = roi[:, :, 1].astype(np.int32)
b = roi[:, :, 2].astype(np.int32)
mask = (r > th) & (r > g * 1.5) & (r > b * 1.5)
oe = (r > 200) & (g > 200) & (b > 200) & (r >= g) & (r >= b) & ((r - g) > 10) & ((r - b) > 10)
combined = (mask | oe).astype(np.uint8) * 255
contours, _ = cv2.findContours(combined, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE)
if not contours:
return None
largest = max(contours, key=cv2.contourArea)
if cv2.contourArea(largest) < 5:
return None
cnt = largest.copy()
for pt in cnt:
pt[0][0] += x1
pt[0][1] += y1
if len(cnt) >= 5:
(ex, ey), (ew, eh), ang = cv2.fitEllipse(cnt)
mask_ellipse = np.zeros((HEIGHT, WIDTH), dtype=np.uint8)
cv2.ellipse(mask_ellipse, (int(ex), int(ey)), (int(ew / 2), int(eh / 2)), ang, 0, 360, 255, -1)
brightness = img_cv[:, :, 0].astype(np.int32) + img_cv[:, :, 1].astype(np.int32) + img_cv[:, :, 2].astype(np.int32)
masked = np.where(mask_ellipse > 0, brightness, 0)
vals = masked[masked > 0]
if len(vals) > 0:
bth = np.percentile(vals, 90)
bmask = (masked >= bth).astype(np.uint8) * 255
bcontours, _ = cv2.findContours(bmask, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE)
if bcontours:
blargest = max(bcontours, key=cv2.contourArea)
if cv2.contourArea(blargest) >= 3 and len(blargest) >= 5:
(ix, iy), _, _ = cv2.fitEllipse(blargest)
return (float(ix), float(iy))
M = cv2.moments(blargest)
if M["m00"] > 0:
return (float(M["m10"] / M["m00"]), float(M["m01"] / M["m00"]))
return (float(ex), float(ey))
M = cv2.moments(cnt)
if M["m00"] > 0:
return (float(M["m10"] / M["m00"]), float(M["m01"] / M["m00"]))
return None
def find_brightest(img_cv, cx, cy, roi_r, th):
x1 = max(0, cx - roi_r)
x2 = min(WIDTH, cx + roi_r)
y1 = max(0, cy - roi_r)
y2 = min(HEIGHT, cy + roi_r)
best_score = 0
best_pos = None
for y in range(y1, y2):
for x in range(x1, x2):
r, g, b = int(img_cv[y, x, 0]), int(img_cv[y, x, 1]), int(img_cv[y, x, 2])
is_red = (r > th and r > g * 1.5 and r > b * 1.5)
is_oe = (r > 200 and g > 200 and b > 200 and r >= g and r >= b and (r - g) > 10 and (r - b) > 10)
if is_red or is_oe:
score = r + g + b
dx, dy = x - cx, y - cy
dist = (dx * dx + dy * dy) ** 0.5
score *= max(0.5, 1.0 - (dist / roi_r) * 0.5)
if score > best_score:
best_score = score
best_pos = (float(x), float(y))
return best_pos
# 打开键盘输入设备
_key_fd = None
try:
_key_fd = open("/dev/input/event0", "rb")
except Exception:
try:
_key_fd = open("/dev/input/event1", "rb")
except Exception:
_key_fd = None
print("=" * 50)
print("激光中心点检测单元测试")
print("=" * 50)
print()
cam = camera.Camera(WIDTH, HEIGHT)
disp = display.Display()
print("[OK] 摄像头和显示初始化完成")
# 初始化激光串口
_laser_on = False
_laser_uart = None
try:
pinmap.set_pin_function("A18", "UART1_RX")
pinmap.set_pin_function("A19", "UART1_TX")
_laser_uart = uart.UART("/dev/ttyS1", 9600)
_laser_uart.read(-1)
print("[OK] 激光串口初始化完成")
except Exception as e:
print(f"[WARN] 激光串口初始化失败: {e}")
LASER_ON = bytes([0xAA, 0x00, 0x01, 0xBE, 0x00, 0x01, 0x00, 0x01, 0xC1])
LASER_OFF = bytes([0xAA, 0x00, 0x01, 0xBE, 0x00, 0x01, 0x00, 0x00, 0xC0])
# 默认开启激光
if _laser_uart:
try:
_laser_uart.write(LASER_ON)
time.sleep_ms(50)
_laser_uart.read(-1)
_laser_on = True
print("[OK] 激光已开启")
except Exception as e:
print(f"[WARN] 开启激光失败: {e}")
print()
pos_ellipse = None
pos_bright = None
frame_count = 0
use_ellipse = True
while not app.need_exit():
frame = cam.read()
if frame is None:
time.sleep_ms(10)
continue
frame_count += 1
if _USE_CV:
img_cv = image.image2cv(frame, False, False)
cx, cy = WIDTH // 2, HEIGHT // 2
t0 = time.ticks_ms()
pos_ellipse = find_ellipse(img_cv, cx, cy, SEARCH_RADIUS, THRESHOLD)
t1 = time.ticks_ms()
pos_bright = find_brightest(img_cv, cx, cy, SEARCH_RADIUS, THRESHOLD)
t2 = time.ticks_ms()
dt_e = abs(time.ticks_diff(t0, t1))
dt_b = abs(time.ticks_diff(t1, t2))
if frame_count % 5 == 0:
e_str = f"({pos_ellipse[0]:.1f},{pos_ellipse[1]:.1f})" if pos_ellipse else "None"
b_str = f"({pos_bright[0]:.1f},{pos_bright[1]:.1f})" if pos_bright else "None"
print(f"[LASER] ellipse={e_str} ({dt_e}ms) brightest={b_str} ({dt_b}ms) "
f"th={THRESHOLD} radius={SEARCH_RADIUS}")
# 叠加显示
pos = pos_ellipse if use_ellipse else pos_bright
h, w = img_cv.shape[:2]
cv2.circle(img_cv, (cx, cy), SEARCH_RADIUS, (0, 255, 0), 1)
cv2.circle(img_cv, (cx, cy), 2, (0, 255, 0), -1)
if pos:
x, y = int(pos[0]), int(pos[1])
cv2.circle(img_cv, (x, y), 6, (0, 0, 255), 2)
cv2.line(img_cv, (x - 14, y), (x + 14, y), (0, 0, 255), 1)
cv2.line(img_cv, (x, y - 14), (x, y + 14), (0, 0, 255), 1)
cv2.putText(img_cv, f"({x},{y})", (x + 10, y - 10),
cv2.FONT_HERSHEY_SIMPLEX, 0.5, (0, 0, 255), 1, cv2.LINE_AA)
info = [
f"pos={pos if pos else 'None'}",
f"method={'ellipse' if use_ellipse else 'brightest'} th={THRESHOLD}",
f"laser={'ON' if _laser_on else 'OFF'}",
]
for i, line in enumerate(info):
cv2.putText(img_cv, line, (8, 20 + i * 22),
cv2.FONT_HERSHEY_SIMPLEX, 0.5, (0, 255, 0), 1, cv2.LINE_AA)
display_frame = image.cv2image(img_cv, False, False)
else:
display_frame = frame
disp.show(display_frame)
# 按键处理(非阻塞)
key = read_key_ev()
if key > 0:
c = chr(key & 0xFF) if key < 256 else ""
if key == 113 or key == 81 or key == 0x1b: # q/Q/ESC
break
if c == "e" or key == 18: # e
use_ellipse = not use_ellipse
print(f"[KEY] Method: {'ellipse' if use_ellipse else 'brightest'}")
if c == "l" or key == 12: # l
_laser_on = not _laser_on
if _laser_uart:
try:
_laser_uart.write(LASER_ON if _laser_on else LASER_OFF)
time.sleep_ms(30)
_laser_uart.read(-1)
print(f"[KEY] Laser: {'ON' if _laser_on else 'OFF'}")
except Exception as e:
print(f"[KEY] Laser error: {e}")
else:
print("[KEY] Laser UART not available")
time.sleep_ms(30)
# 关闭激光
if _laser_on and _laser_uart:
try:
_laser_uart.write(LASER_OFF)
_laser_uart.read(-1)
print("[EXIT] 激光已关闭")
except Exception:
pass
print("[EXIT] 测试结束")
if _key_fd:
_key_fd.close()
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
激光中心点检测单元测试(单文件,无项目依赖)
直接使用 maix 标准库,实现红色激光点坐标检测
运行方式:
python3 test/test_laser_center_point.py
Ctrl+C 退出,按 s 保存截图
"""
from maix import camera, display, image, time, app, uart, pinmap
import os
import struct
import select
_USE_CV = False
try:
import cv2
import numpy as np
_USE_CV = True
except ImportError:
pass
WIDTH = 640
HEIGHT = 480
THRESHOLD = 120
RED_RATIO = 1.3
SEARCH_RADIUS = 60
def read_key_ev():
"""非阻塞读取 /dev/input/event0 按键(返回 key_code 或 -1"""
try:
r, _, _ = select.select([_key_fd], [], [], 0)
if r:
event = _key_fd.read(16)
if len(event) == 16:
_, _, etype, code, value = struct.unpack("IIHHI", event)
if etype == 1 and value == 1:
return code
except Exception:
pass
return -1
def find_ellipse(img_cv, cx, cy, roi_r, th, ratio):
x1 = max(0, cx - roi_r)
x2 = min(WIDTH, cx + roi_r)
y1 = max(0, cy - roi_r)
y2 = min(HEIGHT, cy + roi_r)
roi = img_cv[y1:y2, x1:x2]
if roi.size == 0:
return None
r = roi[:, :, 0].astype(np.int32)
g = roi[:, :, 1].astype(np.int32)
b = roi[:, :, 2].astype(np.int32)
mask = (r > th) & (r > g * ratio) & (r > b * ratio)
oe = (r > 200) & (g > 200) & (b > 200) & (r >= g) & (r >= b) & ((r - g) > 10) & ((r - b) > 10)
combined = (mask | oe).astype(np.uint8) * 255
contours, _ = cv2.findContours(combined, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE)
if not contours:
return None
largest = max(contours, key=cv2.contourArea)
if cv2.contourArea(largest) < 5:
return None
cnt = largest.copy()
for pt in cnt:
pt[0][0] += x1
pt[0][1] += y1
if len(cnt) >= 5:
(ex, ey), (ew, eh), ang = cv2.fitEllipse(cnt)
mask_ellipse = np.zeros((HEIGHT, WIDTH), dtype=np.uint8)
cv2.ellipse(mask_ellipse, (int(ex), int(ey)), (int(ew / 2), int(eh / 2)), ang, 0, 360, 255, -1)
brightness = img_cv[:, :, 0].astype(np.int32) + img_cv[:, :, 1].astype(np.int32) + img_cv[:, :, 2].astype(np.int32)
masked = np.where(mask_ellipse > 0, brightness, 0)
vals = masked[masked > 0]
if len(vals) > 0:
bth = np.percentile(vals, 90)
bmask = (masked >= bth).astype(np.uint8) * 255
bcontours, _ = cv2.findContours(bmask, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE)
if bcontours:
blargest = max(bcontours, key=cv2.contourArea)
if cv2.contourArea(blargest) >= 3 and len(blargest) >= 5:
(ix, iy), _, _ = cv2.fitEllipse(blargest)
return (float(ix), float(iy))
M = cv2.moments(blargest)
if M["m00"] > 0:
return (float(M["m10"] / M["m00"]), float(M["m01"] / M["m00"]))
return (float(ex), float(ey))
M = cv2.moments(cnt)
if M["m00"] > 0:
return (float(M["m10"] / M["m00"]), float(M["m01"] / M["m00"]))
return None
def find_brightest_bytes(frame, cx, cy, roi_r, th, ratio):
"""使用 frame.to_bytes() 两阶段搜索,避免 cv2 转换"""
x1 = max(0, cx - roi_r)
x2 = min(WIDTH, cx + roi_r)
y1 = max(0, cy - roi_r)
y2 = min(HEIGHT, cy + roi_r)
data = frame.to_bytes()
best_score = 0
best_pos = None
# 第一阶段:隔点粗搜
for y in range(y1, y2, 2):
for x in range(x1, x2, 2):
idx = (y * WIDTH + x) * 3
r = data[idx]; g = data[idx+1]; b = data[idx+2]
if (r > th and r > g * ratio and r > b * ratio) or \
(r > 200 and g > 200 and b > 200 and r >= g and r >= b and (r - g) > 10 and (r - b) > 10):
score = r + g + b
dx = x - cx; dy = y - cy
score *= max(0.5, 1.0 - ((dx*dx + dy*dy) ** 0.5 / roi_r) * 0.5)
if score > best_score:
best_score = score
best_pos = (x, y)
if best_pos is None:
return None
# 第二阶段:候选点 7x7 精细搜索
fx, fy = best_pos
x1f = max(0, fx - 3); x2f = min(WIDTH, fx + 4)
y1f = max(0, fy - 3); y2f = min(HEIGHT, fy + 4)
best_bright = 0
final_pos = best_pos
for y in range(y1f, y2f):
for x in range(x1f, x2f):
idx = (y * WIDTH + x) * 3
r = data[idx]; g = data[idx+1]; b = data[idx+2]
if (r > th and r > g * ratio and r > b * ratio) or \
(r > 200 and g > 200 and b > 200 and r >= g and r >= b and (r - g) > 10 and (r - b) > 10):
rgb_sum = r + g + b
if rgb_sum > best_bright:
best_bright = rgb_sum
final_pos = (float(x), float(y))
return final_pos
# 打开键盘输入设备
_key_fd = None
try:
_key_fd = open("/dev/input/event0", "rb")
except Exception:
try:
_key_fd = open("/dev/input/event1", "rb")
except Exception:
_key_fd = None
print("=" * 50)
print("激光中心点检测单元测试")
print("=" * 50)
print()
cam = camera.Camera(WIDTH, HEIGHT)
disp = display.Display()
print("[OK] 摄像头和显示初始化完成")
# 初始化激光串口
_laser_on = False
_laser_uart = None
try:
pinmap.set_pin_function("A18", "UART1_RX")
pinmap.set_pin_function("A19", "UART1_TX")
_laser_uart = uart.UART("/dev/ttyS1", 9600)
_laser_uart.read(-1)
print("[OK] 激光串口初始化完成")
except Exception as e:
print(f"[WARN] 激光串口初始化失败: {e}")
LASER_ON = bytes([0xAA, 0x00, 0x01, 0xBE, 0x00, 0x01, 0x00, 0x01, 0xC1])
LASER_OFF = bytes([0xAA, 0x00, 0x01, 0xBE, 0x00, 0x01, 0x00, 0x00, 0xC0])
# 默认开启激光
if _laser_uart:
try:
_laser_uart.write(LASER_ON)
time.sleep_ms(50)
_laser_uart.read(-1)
_laser_on = True
print("[OK] 激光已开启")
except Exception as e:
print(f"[WARN] 开启激光失败: {e}")
print()
pos_ellipse = None
pos_bright = None
frame_count = 0
use_ellipse = True
while not app.need_exit():
frame = cam.read()
if frame is None:
time.sleep_ms(10)
continue
frame_count += 1
cx, cy = WIDTH // 2, HEIGHT // 2
t0 = time.ticks_ms()
pos_bright = find_brightest_bytes(frame, cx, cy, SEARCH_RADIUS, THRESHOLD, RED_RATIO)
t1 = time.ticks_ms()
pos_ellipse = None
if _USE_CV:
img_cv = image.image2cv(frame, False, False)
t2 = time.ticks_ms()
pos_ellipse = find_ellipse(img_cv, cx, cy, SEARCH_RADIUS, THRESHOLD, RED_RATIO)
t3 = time.ticks_ms()
else:
img_cv = None
t3 = t2 = t1
dt_b = abs(time.ticks_diff(t0, t1))
dt_e = abs(time.ticks_diff(t2, t3))
if frame_count % 5 == 0:
e_str = f"({pos_ellipse[0]:.1f},{pos_ellipse[1]:.1f})" if pos_ellipse else "None"
b_str = f"({pos_bright[0]:.1f},{pos_bright[1]:.1f})" if pos_bright else "None"
print(f"[LASER] ellipse={e_str} ({dt_e}ms) brightest={b_str} ({dt_b}ms) "
f"th={THRESHOLD} ratio={RED_RATIO} radius={SEARCH_RADIUS}")
pos = pos_ellipse if use_ellipse else pos_bright
if img_cv is not None:
cv2.circle(img_cv, (cx, cy), SEARCH_RADIUS, (0, 255, 0), 1)
cv2.circle(img_cv, (cx, cy), 2, (0, 255, 0), -1)
if pos:
x, y = int(pos[0]), int(pos[1])
cv2.circle(img_cv, (x, y), 6, (0, 0, 255), 2)
cv2.line(img_cv, (x - 14, y), (x + 14, y), (0, 0, 255), 1)
cv2.line(img_cv, (x, y - 14), (x, y + 14), (0, 0, 255), 1)
cv2.putText(img_cv, f"({x},{y})", (x + 10, y - 10),
cv2.FONT_HERSHEY_SIMPLEX, 0.5, (0, 0, 255), 1, cv2.LINE_AA)
info = [
f"pos={pos if pos else 'None'}",
f"method={'ellipse' if use_ellipse else 'brightest'} th={THRESHOLD} ratio={RED_RATIO}",
f"laser={'ON' if _laser_on else 'OFF'}",
]
for i, line in enumerate(info):
cv2.putText(img_cv, line, (8, 20 + i * 22),
cv2.FONT_HERSHEY_SIMPLEX, 0.5, (0, 255, 0), 1, cv2.LINE_AA)
display_frame = image.cv2image(img_cv, False, False)
else:
display_frame = frame
disp.show(display_frame)
# 按键处理(非阻塞)
key = read_key_ev()
if key > 0:
c = chr(key & 0xFF) if key < 256 else ""
if key == 113 or key == 81 or key == 0x1b: # q/Q/ESC
break
if c == "e" or key == 18: # e
use_ellipse = not use_ellipse
print(f"[KEY] Method: {'ellipse' if use_ellipse else 'brightest'}")
if c == "l" or key == 12: # l
_laser_on = not _laser_on
if _laser_uart:
try:
_laser_uart.write(LASER_ON if _laser_on else LASER_OFF)
time.sleep_ms(30)
_laser_uart.read(-1)
print(f"[KEY] Laser: {'ON' if _laser_on else 'OFF'}")
except Exception as e:
print(f"[KEY] Laser error: {e}")
else:
print("[KEY] Laser UART not available")
time.sleep_ms(30)
# 关闭激光
if _laser_on and _laser_uart:
try:
_laser_uart.write(LASER_OFF)
_laser_uart.read(-1)
print("[EXIT] 激光已关闭")
except Exception:
pass
print("[EXIT] 测试结束")
if _key_fd:
_key_fd.close()
-18
View File
@@ -1,18 +0,0 @@
from maix import camera, display, image, nn, app
# 1. 初始化模型 (请确保模型文件 .mud 路径正确)
detector = nn.YOLOv5(model="/root/model_279350.mud", dual_buff=True)
# 2. 初始化摄像头,分辨率与模型输入匹配
cam = camera.Camera(detector.input_width(), detector.input_height(), detector.input_format())
disp = display.Display()
# 3. 主循环:实时检测与显示
while not app.need_exit():
img = cam.read() # 从摄像头读取一帧
objs = detector.detect(img, conf_th=0.5, iou_th=0.45) # 执行YOLO11推理
for obj in objs: # 绘制所有检测到的目标
img.draw_rect(obj.x, obj.y, obj.w, obj.h, color=image.COLOR_RED)
msg = f'{detector.labels[obj.class_id]}: {obj.score:.2f}'
img.draw_string(obj.x, obj.y, msg, color=image.COLOR_RED)
disp.show(img) # 更新屏幕显示
-209
View File
@@ -1,209 +0,0 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
摄像头实时 YOLOv5 简易测试脚本。
特点:
- 完全独立脚本,直接 python test/test_yolo_camera_simple.py 运行,不需要传参。
- 不 import config,不依赖项目模块。
- 直接调用 maix.nn.YOLOv5(model=..., dual_buff=False)。
- camera.read() 得到的 Maix image 直接送 det.detect()。
- 在画面上画检测框、类别、置信度,并显示到屏幕。
运行环境:MaixCAM / MaixPy。
"""
import os
CAMERA_WIDTH = 640
CAMERA_HEIGHT = 480
# 默认与主项目 config.TRIANGLE_YOLO_MODEL_PATH 一致(勿用 /root/yolo26_int8.mud,那是占位路径)
_MODEL_DEFAULT = "/maixapp/apps/t11/model_270139.mud"
try:
import config as _cfg
MODEL_PATH = getattr(_cfg, "TRIANGLE_YOLO_MODEL_PATH", _MODEL_DEFAULT) or _MODEL_DEFAULT
except Exception:
MODEL_PATH = _MODEL_DEFAULT
CONF_TH = 0.7
IOU_TH = 0.45
# native: Maix detect 返回框已映射到 camera.read() 图像坐标;letterbox: 需要从网络输入坐标反算
COORD_MODE = "native"
# 只用于 DRAW_ONLY_CLASS_IDS=True 时过滤显示;默认画所有框
CLASS_IDS = (0,)
DRAW_ONLY_CLASS_IDS = False # True=只画 CLASS_IDS 里的类别;False=画所有 YOLO 返回框
def _det_obj_class_id(o):
for key in ("class_id", "cls", "label", "category", "cat_id", "id"):
if hasattr(o, key):
v = getattr(o, key)
if v is None:
continue
try:
return int(float(v))
except (TypeError, ValueError):
continue
return None
def _det_obj_from_seq(t):
if not isinstance(t, (list, tuple)) or len(t) < 6:
return None
class Box:
pass
b = Box()
b.x = float(t[0])
b.y = float(t[1])
b.w = float(t[2])
b.h = float(t[3])
b.score = float(t[4])
b.class_id = int(float(t[5]))
return b
def _normalize_objs(objs):
out = []
for o in objs or []:
if isinstance(o, (list, tuple)):
m = _det_obj_from_seq(o)
if m is not None:
out.append(m)
else:
out.append(o)
return out
def _letterbox_net_to_src_xyxy(x, y, w, h, src_w, src_h, net_w, net_h):
scale = min(net_w / float(src_w), net_h / float(src_h))
new_w = src_w * scale
new_h = src_h * scale
pad_x = (net_w - new_w) * 0.5
pad_y = (net_h - new_h) * 0.5
x0 = (x - pad_x) / scale
y0 = (y - pad_y) / scale
x1 = (x + w - pad_x) / scale
y1 = (y + h - pad_y) / scale
return x0, y0, x1, y1
def _det_to_src_xyxy(o, coord_mode, src_w, src_h, net_w, net_h):
x = float(getattr(o, "x", 0.0))
y = float(getattr(o, "y", 0.0))
w = float(getattr(o, "w", 0.0))
h = float(getattr(o, "h", 0.0))
if coord_mode in ("native", "source", "camera", "full"):
return x, y, x + w, y + h
return _letterbox_net_to_src_xyxy(x, y, w, h, src_w, src_h, net_w, net_h)
def _clip_xywh(x0, y0, x1, y1, src_w, src_h):
x0 = max(0, min(int(round(x0)), src_w - 1))
y0 = max(0, min(int(round(y0)), src_h - 1))
x1 = max(x0 + 1, min(int(round(x1)), src_w))
y1 = max(y0 + 1, min(int(round(y1)), src_h))
return x0, y0, x1 - x0, y1 - y0
def _label(det, cid):
labels = getattr(det, "labels", None)
if labels is None:
return str(cid)
try:
return str(labels[int(cid)])
except Exception:
return str(cid)
def main():
from maix import camera, display, nn, time, image
if not MODEL_PATH or not os.path.isfile(MODEL_PATH):
print("[ERR] 模型文件不存在:", MODEL_PATH)
return
print("[INFO] 初始化 YOLO 模型:", MODEL_PATH)
det = nn.YOLOv26(model=MODEL_PATH, dual_buff=False)
net_w = int(det.input_width())
net_h = int(det.input_height())
print(
"[INFO] net_in=%dx%d conf=%.2f iou=%.2f coord=%s class_ids=%s"
% (net_w, net_h, CONF_TH, IOU_TH, COORD_MODE, str(CLASS_IDS))
)
print("[INFO] 初始化摄像头: %dx%d" % (CAMERA_WIDTH, CAMERA_HEIGHT))
cam = camera.Camera(CAMERA_WIDTH, CAMERA_HEIGHT)
disp = display.Display()
color_cycle = []
for name in ("RED", "GREEN", "BLUE", "ORANGE", "YELLOW", "CYAN", "MAGENTA"):
c = getattr(image, "COLOR_" + name, None)
if c is not None:
color_cycle.append(c)
if not color_cycle:
color_cycle = [getattr(image, "COLOR_RED", 0)]
frame_idx = 0
last_log_ms = time.ticks_ms()
fps_count = 0
while True:
frame = cam.read()
src_w = frame.width()
src_h = frame.height()
t0 = time.ticks_ms()
raw = det.detect(frame, conf_th=CONF_TH, iou_th=IOU_TH)
detect_ms = time.ticks_ms() - t0
objs = _normalize_objs(raw if raw is not None else [])
draw_count = 0
for i, o in enumerate(objs):
cid = _det_obj_class_id(o)
if cid is None:
cid = -1
if DRAW_ONLY_CLASS_IDS and cid not in CLASS_IDS:
continue
try:
score = float(getattr(o, "score", 0.0))
except Exception:
score = 0.0
x0, y0, x1, y1 = _det_to_src_xyxy(o, COORD_MODE, src_w, src_h, net_w, net_h)
ix, iy, iw, ih = _clip_xywh(x0, y0, x1, y1, src_w, src_h)
col = color_cycle[cid % len(color_cycle)] if cid >= 0 else color_cycle[0]
frame.draw_rect(ix, iy, iw, ih, color=col)
frame.draw_string(ix, max(0, iy - 16), "%s %.2f" % (_label(det, cid), score), color=col)
draw_count += 1
frame.draw_string(4, 4, "YOLO boxes:%d draw:%d %dms" % (len(objs), draw_count, detect_ms), color=color_cycle[0])
disp.show(frame)
frame_idx += 1
fps_count += 1
now = time.ticks_ms()
if now - last_log_ms >= 1000:
print(
"[INFO] frame=%d fps=%d raw_boxes=%d draw_boxes=%d detect_ms=%d"
% (frame_idx, fps_count, len(objs), draw_count, detect_ms)
)
fps_count = 0
last_log_ms = now
if __name__ == "__main__":
try:
main()
except KeyboardInterrupt:
print("[INFO] exit")
except Exception as e:
print("[ERR]", e)
try:
import traceback
traceback.print_exc()
except Exception:
pass
-29
View File
@@ -1,29 +0,0 @@
from maix import image, nn, display
# 1. 加载模型
detector = nn.YOLOv8(model="/root/279350.mud", dual_buff=False)
# 2. 加载指定图片(根据模型输入尺寸自动缩放宽高)
img = image.load("/root/tes.jpg")
if img is None:
raise FileNotFoundError("图片加载失败,请检查路径")
# 3. 调整图片尺寸到模型输入要求(可选,detect内部会处理,但提前缩放可提高速度)
# img = img.resize(detector.input_width(), detector.input_height())
# 4. 检测
objs = detector.detect(img, conf_th=0.5, iou_th=0.45)
# 5. 在图片上绘制结果
for obj in objs:
img.draw_rect(obj.x, obj.y, obj.w, obj.h, color=image.COLOR_RED)
msg = f'{detector.labels[obj.class_id]}: {obj.score:.2f}'
img.draw_string(obj.x, obj.y, msg, color=image.COLOR_RED)
# 6. 显示结果(如果设备有屏幕)
disp = display.Display()
disp.show(img)
# 7. 保存结果(可选)
img.save("/root/result.jpg")
print("识别完成,结果已显示并保存为 result.jpg")
+122
View File
@@ -0,0 +1,122 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""Standalone WiFi/GPIO/INA226 isolation test for the official MaixPy tool.
This file intentionally does not import project modules or start project
threads. Select TEST_MODE below, then run the file directly.
"""
import time
from maix import gpio, i2c, network, pinmap
# Change only this value before each run.
# wifi WiFi only
# a25 A25 only
# a23 A23 only
# gpio A23/A26 only
# ina INA226 only
# gpio_ina GPIOs, then INA226
# a25_wifi A25, then WiFi
# a23_wifi A23, then WiFi
# all GPIOs, INA226, then WiFi
TEST_MODE = "a25_wifi"
WIFI_SSID = "sheling4b02-5G"
WIFI_PASSWORD = "Aa12345678"
WIFI_TIMEOUT_S = 20
I2C_BUS_NUM = 5
INA226_ADDR = 0x40
def init_leds():
return init_selected_leds(True, True)
def init_selected_leds(use_a26, use_a23):
outputs = []
if use_a26:
print("Initializing A25 -> GPIOA25")
pinmap.set_pin_function("A25", "GPIOA25")
green = gpio.GPIO("GPIOA25", gpio.Mode.OUT)
green.value(0)
outputs.append(("GPIOA25", green))
print("GPIOA25 initialized LOW")
if use_a23:
print("Initializing A23 -> GPIOA23")
pinmap.set_pin_function("A23", "GPIOA23")
red = gpio.GPIO("GPIOA23", gpio.Mode.OUT)
red.value(0)
outputs.append(("GPIOA23", red))
print("GPIOA23 initialized LOW")
return outputs
def test_ina226():
# Match the board mapping used by the application before opening I2C5.
pinmap.set_pin_function("A15", "I2C5_SCL")
pinmap.set_pin_function("A27", "I2C5_SDA")
print("A15/A27 configured for I2C5")
print("Initializing I2C bus", I2C_BUS_NUM)
bus = i2c.I2C(I2C_BUS_NUM, i2c.Mode.MASTER)
print("Reading INA226 at 0x%02X" % INA226_ADDR)
config = bus.readfrom_mem(INA226_ADDR, 0x00, 2)
voltage_raw = bus.readfrom_mem(INA226_ADDR, 0x02, 2)
voltage = ((voltage_raw[0] << 8) | voltage_raw[1]) * 1.25 / 1000
print("INA226 config=0x%02X%02X voltage=%.3fV" % (config[0], config[1], voltage))
return bus
def test_wifi():
print("Starting MaixPy WiFi connection...")
wifi = network.wifi.Wifi()
result = wifi.connect(WIFI_SSID, WIFI_PASSWORD, wait=True, timeout=WIFI_TIMEOUT_S)
print("WiFi connect result:", result)
print("WiFi connected:", wifi.is_connected())
try:
print("WiFi IP:", wifi.get_ip())
except Exception as exc:
print("WiFi status query failed:", exc)
def main():
valid = ("wifi", "a25", "a23", "gpio", "ina", "gpio_ina", "a25_wifi", "a23_wifi", "all")
mode = TEST_MODE.lower()
if mode not in valid:
print("TEST_MODE must be one of:", ", ".join(valid))
return 1
leds = []
try:
print("=== Standalone WiFi/GPIO/INA226 isolation ===")
print("mode:", mode)
if mode in ("a25", "a25_wifi"):
leds = init_selected_leds(True, False)
time.sleep(1)
elif mode in ("a23", "a23_wifi"):
leds = init_selected_leds(False, True)
time.sleep(1)
elif mode in ("gpio", "gpio_ina", "all"):
leds = init_leds()
time.sleep(1)
if mode in ("ina", "gpio_ina", "all"):
test_ina226()
time.sleep(1)
if mode in ("wifi", "a25_wifi", "a23_wifi", "all"):
test_wifi()
print("TEST COMPLETE")
return 0
except Exception as exc:
print("TEST FAILED:", repr(exc))
return 1
finally:
for name, led in leds:
try:
led.value(0)
print(name, "LOW")
except Exception as exc:
print(name, "cleanup failed:", exc)
main()
+88
View File
@@ -0,0 +1,88 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""Standalone WiFi/GPIO isolation test.
This script intentionally does not import any project module. It only tests
MaixPy WiFi startup with optional A23/A26 GPIO initialization.
"""
import time
from maix import gpio, network, pinmap
GREEN_PIN = "A26"
GREEN_GPIO = "GPIOA26"
RED_PIN = "A23"
RED_GPIO = "GPIOA23"
# Run this file directly from the official MaixPy tool.
# Change only TEST_MODE between runs: none -> a26 -> a23 -> both.
TEST_MODE = "none"
WIFI_SSID = "sheling4b02-5G"
WIFI_PASSWORD = "Aa12345678"
WIFI_TIMEOUT_S = 20
def init_gpio(mode):
outputs = []
if mode in ("a26", "both"):
pinmap.set_pin_function(GREEN_PIN, GREEN_GPIO)
green = gpio.GPIO(GREEN_GPIO, gpio.Mode.OUT)
green.value(1)
outputs.append((GREEN_GPIO, green))
print("GPIOA26 initialized HIGH")
if mode in ("a23", "both"):
pinmap.set_pin_function(RED_PIN, RED_GPIO)
red = gpio.GPIO(RED_GPIO, gpio.Mode.OUT)
red.value(1)
outputs.append((RED_GPIO, red))
print("GPIOA23 initialized HIGH")
return outputs
def connect_wifi(ssid, password, timeout_s):
print("Starting MaixPy WiFi connection...")
wifi = network.wifi.Wifi()
result = wifi.connect(ssid, password, wait=True, timeout=timeout_s)
print("WiFi connect result:", result)
try:
print("WiFi connected:", wifi.is_connected())
print("WiFi IP:", wifi.get_ip())
except Exception as exc:
print("WiFi status query failed:", exc)
return result
def main():
mode = TEST_MODE.lower()
if mode not in ("none", "a26", "a23", "both"):
print("TEST_MODE must be none, a26, a23, or both")
return 1
ssid = WIFI_SSID
password = WIFI_PASSWORD
timeout_s = WIFI_TIMEOUT_S
print("=== Standalone WiFi/GPIO isolation ===")
print("mode:", mode)
print("ssid:", ssid)
outputs = []
try:
outputs = init_gpio(mode)
time.sleep(1)
connect_wifi(ssid, password, timeout_s)
return 0
except Exception as exc:
print("TEST FAILED:", repr(exc))
return 1
finally:
for gpio_name, output in outputs:
try:
output.value(0)
print(gpio_name, "LOW")
except Exception as exc:
print(gpio_name, "cleanup failed:", exc)
if __name__ == "__main__":
raise SystemExit(main())
+4 -171
View File
@@ -22,143 +22,6 @@ def _log(msg):
pass
def _read_triangle_direction_cfg():
"""读取 config 中三角形方向/中心距校验参数。"""
try:
import config as cfg
return {
"enable": bool(getattr(cfg, "TRIANGLE_DIRECTION_VALIDATE_ENABLE", True)),
"min_pass": int(getattr(cfg, "TRIANGLE_DIRECTION_MIN_PASS", 3)),
"dot_min": float(getattr(cfg, "TRIANGLE_DIRECTION_DOT_MIN", 0.0)),
"to_center_dot_min": float(
getattr(cfg, "TRIANGLE_DIRECTION_TO_CENTER_DOT_MIN", 0.35)
),
"center_dist_enable": bool(
getattr(cfg, "TRIANGLE_CENTER_DISTANCE_VALIDATE_ENABLE", True)
),
"center_dist_tol": float(
getattr(cfg, "TRIANGLE_CENTER_DISTANCE_RATIO_TOL", 0.45)
),
}
except Exception:
return {
"enable": True,
"min_pass": 3,
"dot_min": 0.0,
"to_center_dot_min": 0.35,
"center_dist_enable": True,
"center_dist_tol": 0.45,
}
def _quad_combo_orient_penalty(cands_4):
"""
四点组合评分用的方向惩罚(原 _score_quad 内 orient_pen 逻辑)。
TRIANGLE_DIRECTION_VALIDATE_ENABLE=False 时调用方应跳过(不加罚)。
"""
orient_pen = 0.0
orient_vote = []
for c in cands_4:
cen = np.array(c["center_px"], dtype=np.float32)
rpt = np.array(c["right_pt"], dtype=np.float32)
vx = float(cen[0] - rpt[0])
vy = float(cen[1] - rpt[1])
if abs(vx) < 1e-6 or abs(vy) < 1e-6:
orient_pen += 1.0
orient_vote.append(None)
continue
if abs(vx) < abs(vy) * 0.15 or abs(vy) < abs(vx) * 0.15:
orient_pen += 0.5
if vx > 0 and vy > 0:
orient_vote.append(0)
elif vx < 0 and vy > 0:
orient_vote.append(1)
elif vx > 0 and vy < 0:
orient_vote.append(2)
else:
orient_vote.append(3)
valid_votes = [v for v in orient_vote if v is not None]
if valid_votes:
from collections import Counter
vc = Counter(valid_votes)
orient_pen += max(0, max(vc.values()) - 1) * 0.8
return orient_pen
def _marker_inward_unit(marker):
"""从直角顶点指向三角内部的单位向量;marker['center'] 为直角顶点。"""
right = np.array(marker["center"], dtype=np.float64)
corners = marker.get("corners")
if not corners or len(corners) < 3:
return None
cen = np.mean(np.array(corners, dtype=np.float64), axis=0)
inv = cen - right
n = float(np.linalg.norm(inv))
if n < 1e-6:
return None
return inv / n
def _validate_triangle_direction(marker_centers, tri_markers, cfg):
"""
校验:四角到候选靶心距离近似一致;各真实黑三角朝向靶心。
仅统计 tri_markers 中真实检出的角(不含几何补全的虚拟点)。
Returns:
(ok: bool, reason: str)
"""
if not cfg.get("enable", True):
return True, ""
pts = np.array(marker_centers, dtype=np.float64).reshape(-1, 2)
if len(pts) < 3:
return True, ""
quad_center = np.mean(pts, axis=0)
if cfg.get("center_dist_enable", True) and len(pts) >= 3:
dists = np.linalg.norm(pts - quad_center, axis=1)
mean_d = float(np.mean(dists))
if mean_d > 1e-6:
ratio = (float(np.max(dists)) - float(np.min(dists))) / mean_d
tol = float(cfg.get("center_dist_tol", 0.45))
if ratio > tol:
return False, f"center_dist_ratio={ratio:.2f}>{tol:.2f}"
dot_need = max(
float(cfg.get("dot_min", 0.0)),
float(cfg.get("to_center_dot_min", 0.35)),
)
pass_n = 0
check_n = 0
for m in tri_markers or []:
if m.get("center") is None:
continue
check_n += 1
right = np.array(m["center"], dtype=np.float64)
to_center = quad_center - right
nc = float(np.linalg.norm(to_center))
if nc < 1e-6:
continue
inward = _marker_inward_unit(m)
if inward is None:
continue
dot_tc = float(np.dot(inward, to_center / nc))
if dot_tc >= dot_need:
pass_n += 1
if check_n == 0:
return True, ""
min_pass = int(cfg.get("min_pass", 3))
min_pass = max(1, min(min_pass, check_n))
if pass_n < min_pass:
return False, (
f"direction_pass={pass_n}/{check_n} need>={min_pass} "
f"(dot>={dot_need:.2f})"
)
return True, ""
def _gray_suppress_bright_by_v(img_rgb, v_above: int):
"""
RGB 输入:在 HSV 的 V 上,将亮度 >= v_above 的像素灰度置为 255。
@@ -361,7 +224,7 @@ def detect_triangle_markers(
blackhat_kernel_frac = 0.018
try:
import config as _tcfg
_timing_log = bool(getattr(_tcfg, "ARCHERY_TIMING_ENABLE", True)) and bool(getattr(_tcfg, "TRIANGLE_TIMING_LOG", True))
_timing_log = bool(getattr(_tcfg, "TRIANGLE_TIMING_LOG", True))
except Exception:
_timing_log = True
@@ -759,8 +622,6 @@ def detect_triangle_markers(
bot_pair = sorted(by_y[2:], key=lambda i: pts_4[i][0])
return top_pair[0], bot_pair[0], bot_pair[1], top_pair[1]
_dir_cfg_combo = _read_triangle_direction_cfg()
def _score_quad(cands_4):
pts = [np.array(c["center_px"]) for c in cands_4]
legs = [c["avg_leg"] for c in cands_4]
@@ -780,13 +641,7 @@ def detect_triangle_markers(
med_l = float(np.median(legs))
leg_dev = max(abs(l - med_l) / (med_l + 1e-6) for l in legs)
orient_pen = (
_quad_combo_orient_penalty(cands_4)
if _dir_cfg_combo.get("enable", True)
else 0.0
)
score = (diag_ratio - 1.0) * 3.0 + (h_ratio - 1.0) + (v_ratio - 1.0) + leg_dev * 2.0 + orient_pen
score = (diag_ratio - 1.0) * 3.0 + (h_ratio - 1.0) + (v_ratio - 1.0) + leg_dev * 2.0
return score, (tl, bl, br, tr)
assigned = None
@@ -1077,8 +932,6 @@ def _assign_marker_ids_from_filtered(filtered, verbose=True):
bot_pair = sorted(by_y[2:], key=lambda i: pts_4[i][0])
return top_pair[0], bot_pair[0], bot_pair[1], top_pair[1]
_dir_cfg_combo = _read_triangle_direction_cfg()
def _score_quad(cands_4):
pts = [np.array(c["center_px"]) for c in cands_4]
legs = [c["avg_leg"] for c in cands_4]
@@ -1094,12 +947,7 @@ def _assign_marker_ids_from_filtered(filtered, verbose=True):
v_ratio = max(s_left, s_right) / (min(s_left, s_right) + 1e-6)
med_l = float(np.median(legs))
leg_dev = max(abs(l - med_l) / (med_l + 1e-6) for l in legs)
orient_pen = (
_quad_combo_orient_penalty(cands_4)
if _dir_cfg_combo.get("enable", True)
else 0.0
)
score = (diag_ratio - 1.0) * 3.0 + (h_ratio - 1.0) + (v_ratio - 1.0) + leg_dev * 2.0 + orient_pen
score = (diag_ratio - 1.0) * 3.0 + (h_ratio - 1.0) + (v_ratio - 1.0) + leg_dev * 2.0
return score, (tl, bl, br, tr)
assigned = None
@@ -1265,7 +1113,7 @@ def try_triangle_scoring(
try:
import config as _cfg_tl
_try_timing_log = bool(getattr(_cfg_tl, "ARCHERY_TIMING_ENABLE", True)) and bool(getattr(_cfg_tl, "TRIANGLE_TIMING_LOG", True))
_try_timing_log = bool(getattr(_cfg_tl, "TRIANGLE_TIMING_LOG", True))
_crop_min_side = int(getattr(_cfg_tl, "TRIANGLE_CROP_ROI_MIN_SIDE_PX", 64))
except Exception:
_try_timing_log = True
@@ -1885,21 +1733,6 @@ def try_triangle_scoring(
"is_virtual": bool(_is_virtual),
})
# ---------- 方向 / 中心距校验(config.TRIANGLE_DIRECTION_* ----------
_dir_cfg = _read_triangle_direction_cfg()
_dir_ok, _dir_reason = _validate_triangle_direction(
marker_centers, tri_markers, _dir_cfg
)
if not _dir_ok:
_log(f"[TRI] 方向校验失败: {_dir_reason}")
if _try_timing_log:
_log(
f"[TRI] timing_ms(try_triangle): {_tri_yolo_part} "
f"geometry={(time.perf_counter() - _t_seg) * 1000:.1f} "
f"total_try={(time.perf_counter() - _t_try0) * 1000:.1f} (方向校验失败)"
)
return out
# ---------- 结果有效性校验(防 nan/inf 与退化角点) ----------
try:
import config as _cfg
+33
View File
@@ -0,0 +1,33 @@
# 1.2.0 开始使用C++编译成.so,替换部分代码
# 1.2.1 ota使用加密包
# 1.2.2 支持wifi ota,并且设定时区,并使用单独线程保存图片
# 1.2.3 修改ADC_TRIGGER_THRESHOLD 为2300,支持上传日志到服务器
# 1.2.4 修改ADC_TRIGGER_THRESHOLD 为3000,并默认关闭摄像头的显示,并把ADC的采样间隔从50ms降低到10ms
# 1.2.5 支持空气传感器采样,并默认关闭日志。优化断网时的发送队列丢消息问题,解决 WiFi 断线检测不可靠问题。
# 1.2.6 在链接 wifi 前先判断 wifi 的可用性,假如不可用,则不落盘。增加日志批量压缩上传功能
# 1.2.7 修复OTA失败的bug, 空气压力传感器的阈值是2500
# 1.2.8 (1) 加快 wifi 下数据传输的速度。(2) 调整射箭时处理的逻辑,优先上报数据,再存照片之类的操作。(3)假如是用户打开激光的,射箭触发后不再关闭激光,因为是调瞄阶段
# 1.2.9 增加电源板的控制和自动关机的功能
# 1.2.10 config formal
# 1.2.11 增加三角形的单应性算法,适配对应的靶纸
# 1.2.110 关掉了黑色三角形算法,只用于测试
# 1.2.13 修改wifi连接
# 1.2.14 修改了icc登录部分
# 2.15.3 新版本ota,去除ai算环数方法
# 2.15.4 更新版本号
# 2.15.5 打印ota进度
# 2.15.6 更新版本号
# 2.15.7 更新版本号
# 2.15.8 启动不加载预加载yolo
# 2.15.9 20cm
# 2.15.10 不保存图片
# 2.15.11 优化内存
# 2.15.12 优化算法
# 2.15.13 优化算法
# 2.15.14 优化算法
# 2.15.15 优化wifi连接
# 2.15.16 修复wifi连接问题
# 2.15.17 修复wifi连接问题
# 2.15.18 wifi连接成功重新登录
# 2.16.4 优化射箭延迟
# 2.17.0 yolo标靶类别识别
+1 -23
View File
@@ -4,28 +4,6 @@
应用版本号
每次 OTA 更新时只需要更新这个文件中的版本号
"""
VERSION = '1.2.15.1'
# 1.2.0 开始使用C++编译成.so,替换部分代码
# 1.2.1 ota使用加密包
# 1.2.2 支持wifi ota,并且设定时区,并使用单独线程保存图片
# 1.2.3 修改ADC_TRIGGER_THRESHOLD 为2300,支持上传日志到服务器
# 1.2.4 修改ADC_TRIGGER_THRESHOLD 为3000,并默认关闭摄像头的显示,并把ADC的采样间隔从50ms降低到10ms
# 1.2.5 支持空气传感器采样,并默认关闭日志。优化断网时的发送队列丢消息问题,解决 WiFi 断线检测不可靠问题。
# 1.2.6 在链接 wifi 前先判断 wifi 的可用性,假如不可用,则不落盘。增加日志批量压缩上传功能
# 1.2.7 修复OTA失败的bug, 空气压力传感器的阈值是2500
# 1.2.8 (1) 加快 wifi 下数据传输的速度。(2) 调整射箭时处理的逻辑,优先上报数据,再存照片之类的操作。(3)假如是用户打开激光的,射箭触发后不再关闭激光,因为是调瞄阶段
# 1.2.9 增加电源板的控制和自动关机的功能
# 1.2.10 config formal
# 1.2.11 增加三角形的单应性算法,适配对应的靶纸
# 1.2.110 关掉了黑色三角形算法,只用于测试
# 1.2.13 修改wifi连接
# 1.2.14 修改了icc登录部分
# 1.2.15.1 增加了标靶判断 20 40
# 1.2.16.1 增加激光校准,三角形方向判断,时间开关
VERSION = '3.0.5'
+35 -150
View File
@@ -10,7 +10,6 @@ import os
import math
import threading
import queue
import time
from maix import image
import config
from logger_manager import logger_manager
@@ -532,14 +531,11 @@ def detect_circle_v3(frame, laser_point=None, img_cv=None):
if img_cv is None:
img_cv = image.image2cv(frame, False, False)
logger = logger_manager.logger
_timing_on = bool(getattr(config, "VISION_TIMING_ENABLE", True))
_t0 = time.perf_counter() if _timing_on else None
_t1 = _t2 = _t3 = _t4 = _t5 = None
from datetime import datetime
logger.debug(f"[detect_circle_v3] begin {datetime.now()}")
# -- 1. 缩图加速(与三角形路径保持一致)
h_orig, w_orig = img_cv.shape[:2]
MAX_DET_DIM = 320
MAX_DET_DIM = 480
long_side = max(h_orig, w_orig)
if long_side > MAX_DET_DIM:
det_scale = MAX_DET_DIM / long_side
@@ -558,8 +554,6 @@ def detect_circle_v3(frame, laser_point=None, img_cv=None):
ellipse_params = None
logger.debug(f"[detect_circle_v3] step 1 fin {datetime.now()}")
if _timing_on:
_t1 = time.perf_counter()
# -- 2. HSV + 黄色掩码
hsv = cv2.cvtColor(img_det, cv2.COLOR_RGB2HSV)
@@ -573,26 +567,25 @@ def detect_circle_v3(frame, laser_point=None, img_cv=None):
mask_yellow = cv2.morphologyEx(mask_yellow, cv2.MORPH_CLOSE, kernel)
logger.debug(f"[detect_circle_v3] step 2 fin {datetime.now()}")
if _timing_on:
_t2 = time.perf_counter()
_t3 = time.perf_counter()
# -- 3. 红色掩码:在循环外只算一次
mask_red = cv2.bitwise_or(
cv2.inRange(hsv, np.array([0, 80, 0]), np.array([10, 255, 255])),
cv2.inRange(hsv, np.array([170, 80, 0]), np.array([180, 255, 255])),
cv2.inRange(hsv, np.array([0, 30, 20]), np.array([12, 255, 255])),
cv2.inRange(hsv, np.array([168, 30, 20]), np.array([180, 255, 255])),
)
kernel_red = cv2.getStructuringElement(cv2.MORPH_ELLIPSE, (5, 5))
mask_red = cv2.morphologyEx(mask_red, cv2.MORPH_CLOSE, kernel_red)
# 再加一次膨胀,加厚环状区域避免碎片化
mask_red = cv2.dilate(mask_red, kernel_red, iterations=1)
contours_red, _ = cv2.findContours(mask_red, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE)
# 预先把红色轮廓筛选成 (center, radius) 列表,后续直接查表
red_candidates = []
for cnt_r in contours_red:
ar = cv2.contourArea(cnt_r)
if ar <= 50:
if ar <= 10:
continue
pr = cv2.arcLength(cnt_r, True)
if pr <= 0 or (4 * np.pi * ar) / (pr * pr) <= 0.6:
if pr <= 0 or (4 * np.pi * ar) / (pr * pr) <= 0.2:
continue
if len(cnt_r) >= 5:
(xr, yr), (wr, hr), _ = cv2.fitEllipse(cnt_r)
@@ -602,22 +595,19 @@ def detect_circle_v3(frame, laser_point=None, img_cv=None):
red_candidates.append({"center": (int(xr), int(yr)), "radius": int(rr)})
logger.debug(f"[detect_circle_v3] step 3 fin {datetime.now()}")
if _timing_on:
_t3 = time.perf_counter()
_t4 = time.perf_counter()
# -- 4. 黄色轮廓循环(复用上面的红色候选列表)
contours_yellow, _ = cv2.findContours(mask_yellow, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE)
valid_targets = []
for cnt_yellow in contours_yellow:
area = cv2.contourArea(cnt_yellow)
if area <= 50:
if area <= 15:
continue
perimeter = cv2.arcLength(cnt_yellow, True)
if perimeter <= 0:
continue
circularity = (4 * np.pi * area) / (perimeter * perimeter)
if circularity <= 0.7:
if circularity <= 0.5:
continue
if logger:
logger.info(f"[target] -> 面积:{area:.1f}, 圆度:{circularity:.2f}")
@@ -637,7 +627,11 @@ def detect_circle_v3(frame, laser_point=None, img_cv=None):
ddx = yellow_center[0] - rc["center"][0]
ddy = yellow_center[1] - rc["center"][1]
dist_centers = math.hypot(ddx, ddy)
if dist_centers < yellow_radius * 1.5 and rc["radius"] > yellow_radius * 0.8:
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:
logger.info(f"[target] -> 找到匹配的红圈: 黄心({yellow_center}), "
f"红心({rc['center']}), 距离:{dist_centers:.1f}, "
@@ -650,13 +644,19 @@ def detect_circle_v3(frame, laser_point=None, img_cv=None):
})
matched = True
break
if not matched and logger:
logger.debug("Debug -> 未找到匹配的红色圆圈,可能是误识别")
if not matched:
# 黄圈高置信度兜底:大且圆时跳过红圈验证
if area > 30 and circularity > 0.8:
valid_targets.append({
"center": yellow_center,
"radius": yellow_radius,
"ellipse": yellow_ellipse,
"area": area,
})
elif logger:
logger.debug("Debug -> 未找到匹配的红色圆圈,可能是误识别")
logger.debug(f"[detect_circle_v3] step 4 fin {datetime.now()}")
if _timing_on:
_t4 = time.perf_counter()
_t5 = time.perf_counter()
# -- 5. 选最佳目标,坐标还原到原始分辨率
if valid_targets:
@@ -684,20 +684,7 @@ def detect_circle_v3(frame, laser_point=None, img_cv=None):
ellipse_params = be
best_radius1 = best_radius * 5
result_img = image.cv2image(img_cv, False, False)
if _timing_on:
_t5 = time.perf_counter()
_t_all = (_t5 - _t0) * 1000
_ms1 = (_t1 - _t0) * 1000
_ms2 = (_t2 - _t1) * 1000
_ms3 = (_t3 - _t2) * 1000
_ms4 = (_t4 - _t3) * 1000
_ms5 = (_t5 - _t4) * 1000
logger.info(
f"[VISION timing] total={_t_all:.1f}ms "
f"resize={_ms1:.1f} hsv_yellow={_ms2:.1f} "
f"red_mask={_ms3:.1f} yellow_loop={_ms4:.1f} "
f"select_cv2img={_ms5:.1f}"
)
logger.debug(f"[detect_circle_v3] step 5 fin {datetime.now()}")
return result_img, best_center, best_radius, method, best_radius1, ellipse_params
def estimate_distance(pixel_radius):
@@ -810,12 +797,12 @@ def estimate_pixel(physical_distance_cm, target_distance_m):
def _save_shot_image_impl(img_cv, center, radius, method, ellipse_params,
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) 上绘制标注并保存
save_shot_image同步和存图 worker异步调用
"""
if not config.SAVE_IMAGE_ENABLED:
if not config.SAVE_IMAGE_ENABLED and not force_save:
return None
if photo_dir is None:
photo_dir = config.PHOTO_DIR
@@ -949,58 +936,14 @@ def start_save_shot_worker():
logger.info("[VISION] 存图 worker 线程已启动")
def enqueue_save_raw_shot(frame, shot_id=None, photo_dir=None):
"""
异步保存射箭原图无算法标注 SAVE_IMAGE_ENABLED SAVE_RAW_SHOT_IMAGE_ENABLED
文件名{photo_dir}/shot_{shot_id}_raw.jpg
"""
if not getattr(config, "SAVE_RAW_SHOT_IMAGE_ENABLED", False):
return
if not getattr(config, "SAVE_IMAGE_ENABLED", True):
return
if not shot_id:
return
if photo_dir is None:
photo_dir = config.PHOTO_DIR
try:
img_cv = image.image2cv(frame, False, False)
img_copy = np.copy(img_cv)
except Exception as e:
logger = logger_manager.logger
if logger:
logger.error(f"[VISION] enqueue_save_raw_shot 复制图像失败: {e}")
return
def _job():
try:
try:
if photo_dir not in os.listdir("/root"):
os.mkdir(photo_dir)
except Exception:
pass
filename = f"{photo_dir}/shot_{shot_id}_raw.jpg"
out = image.cv2image(img_copy, False, False)
out.save(filename)
logger = logger_manager.logger
if logger:
logger.info(f"[VISION] 已保存射箭原图: {filename}")
prune_old_images_in_dir(photo_dir, config.MAX_IMAGES, logger, "[VISION]")
except Exception as e:
logger = logger_manager.logger
if logger:
logger.error(f"[VISION] 保存射箭原图失败: {e}")
threading.Thread(target=_job, daemon=True).start()
def enqueue_save_shot(result_img, center, radius, method, ellipse_params,
laser_point, distance_m, shot_id=None, photo_dir=None,
yolo_roi_xyxy=None):
yolo_roi_xyxy=None, force_save=False):
"""
将存图任务放入队列 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
if photo_dir is None:
photo_dir = config.PHOTO_DIR
@@ -1023,6 +966,7 @@ def enqueue_save_shot(result_img, center, radius, method, ellipse_params,
shot_id,
photo_dir,
yolo_roi_xyxy,
force_save,
)
try:
_save_queue.put_nowait(task)
@@ -1034,12 +978,12 @@ def enqueue_save_shot(result_img, center, radius, method, ellipse_params,
def save_shot_image(result_img, center, radius, method, ellipse_params,
laser_point, distance_m, shot_id=None, photo_dir=None,
yolo_roi_xyxy=None):
yolo_roi_xyxy=None, force_save=False):
"""
保存射击图像带标注同步调用会阻塞
主流程建议使用 enqueue_save_shot此处保留供校准测试等场景使用
"""
if not config.SAVE_IMAGE_ENABLED:
if not config.SAVE_IMAGE_ENABLED and not force_save:
return None
if photo_dir is None:
photo_dir = config.PHOTO_DIR
@@ -1056,6 +1000,7 @@ def save_shot_image(result_img, center, radius, method, ellipse_params,
shot_id,
photo_dir,
yolo_roi_xyxy,
force_save,
)
except Exception as e:
logger = logger_manager.logger
@@ -1083,63 +1028,3 @@ def detect_target(frame, laser_point=None):
logger.debug("[VISION] 使用传统黄色靶心检测")
return detect_circle_v3(frame, laser_point)
def sample_target_rgb_at_physical_radius(frame, target_center, target_radius_px, radius_cm=None, angles_deg=None, patch_half_px=None, black_thresh=None, timing=False):
"""
在物方半径位置采样 RGB判断黑/白靶
返回: dict {ok, is_black, mean_rgb, samples, black_ratio, elapsed_ms}
"""
logger = logger_manager.logger
if target_center is None or target_radius_px is None:
return {"ok": False, "reason": "no_target", "is_black": None, "elapsed_ms": 0.0}
radius_cm = float(radius_cm if radius_cm is not None else getattr(config, "TRIANGLE_SAMPLE_RADIUS_CM", 15.0))
angles_deg = tuple(angles_deg if angles_deg is not None else getattr(config, "TRIANGLE_SAMPLE_ANGLES_DEG", (0, 90, 180, 270)))
patch_half_px = int(patch_half_px if patch_half_px is not None else getattr(config, "TRIANGLE_SAMPLE_PATCH_HALF_PX", 2))
black_thresh = float(black_thresh if black_thresh is not None else getattr(config, "TRIANGLE_SAMPLE_BLACK_THRESH", 30.0))
timing_on = bool(timing) and bool(getattr(config, "TRIANGLE_SAMPLE_TIMING_ENABLE", True))
t0 = time.perf_counter() if timing_on else None
try:
img_cv = image.image2cv(frame, False, False)
h, w = img_cv.shape[:2]
cx, cy = float(target_center[0]), float(target_center[1])
scale = float(target_radius_px) / max(radius_cm, 1e-6)
samples = []
black_count = 0
for ang in angles_deg:
rad = math.radians(float(ang))
sx = int(round(cx + math.cos(rad) * radius_cm * scale))
sy = int(round(cy + math.sin(rad) * radius_cm * scale))
x0 = max(0, sx - patch_half_px)
y0 = max(0, sy - patch_half_px)
x1 = min(w, sx + patch_half_px + 1)
y1 = min(h, sy + patch_half_px + 1)
if x1 <= x0 or y1 <= y0:
continue
patch = img_cv[y0:y1, x0:x1]
mean_rgb = patch.reshape(-1, 3).mean(axis=0)
is_black = bool(np.all(mean_rgb < black_thresh))
black_count += 1 if is_black else 0
samples.append({"angle": float(ang), "xy": (sx, sy), "mean_rgb": tuple(float(v) for v in mean_rgb), "is_black": is_black})
black_ratio = float(black_count) / float(len(samples) or 1)
out = {
"ok": len(samples) > 0,
"is_black": black_ratio >= 0.5,
"mean_rgb": tuple(float(v) for v in (np.mean([s["mean_rgb"] for s in samples], axis=0) if samples else (0, 0, 0))),
"samples": samples,
"black_ratio": black_ratio,
"elapsed_ms": (time.perf_counter() - t0) * 1000.0 if timing_on else 0.0,
}
if logger:
logger.info(
f"[TRI-SAMPLE] radius_cm={radius_cm:.1f} black_thresh={black_thresh:.1f} "
f"black_ratio={black_ratio:.2f} is_black={out['is_black']} "
f"elapsed_ms={out['elapsed_ms']:.1f} samples={len(samples)}"
)
return out
except Exception as e:
if logger:
logger.error(f"[TRI-SAMPLE] 采样失败: {e}")
return {"ok": False, "reason": str(e), "is_black": None, "elapsed_ms": 0.0}
+42 -25
View File
@@ -41,6 +41,7 @@ class WiFiManager:
# WiFi 质量监测(后台线程)
self._wifi_quality_monitor_thread = None
self._wifi_quality_stop_event = threading.Event()
self._wifi_quality_lock = threading.Lock()
self._last_wifi_rtt_ms = None # 最近一次测量的 RTT
self._last_wifi_rssi_dbm = None # 最近一次测量的 RSSI
@@ -238,7 +239,6 @@ class WiFiManager:
old_conf = _read_text(conf_path)
old_boot_ssid = _read_text(ssid_file)
old_boot_pass = _read_text(pass_file)
old_boot_wpa = _read_text(boot_wpa_path) if os.path.exists(boot_wpa_path) else None
try:
try:
@@ -250,9 +250,13 @@ class WiFiManager:
_write_text(conf_path, full_conf)
except Exception:
pass
_write_text(boot_wpa_path, full_conf)
# 删除 wpa_supplicant.conf,让 S30wifi 回退读 ssid/pass
try:
if os.path.exists(boot_wpa_path):
os.remove(boot_wpa_path)
except Exception:
pass
# 仍写入 ssid/pass,便于其它脚本/人工查看;S30wifi 优先使用 wpa_supplicant.conf
_write_text(ssid_file, ssid.strip())
_write_text(pass_file, password.strip())
@@ -292,7 +296,6 @@ class WiFiManager:
if not persist:
# 不持久化:把 /boot 恢复成旧值(不重启,当前连接保持不变)
_restore_boot(old_boot_ssid, old_boot_pass)
_restore_boot_wpa(old_boot_wpa)
self.logger.info("[WIFI] 网络验证通过,但按 persist=False 回滚 /boot 凭证(不重启)")
else:
self.logger.info("[WIFI] 网络验证通过,/boot 凭证已保留(持久化)")
@@ -306,7 +309,6 @@ class WiFiManager:
except Exception as e:
# 失败:回滚 /boot 和 /etc,重启 WiFi 恢复旧网络
_restore_boot(old_boot_ssid, old_boot_pass)
_restore_boot_wpa(old_boot_wpa)
try:
if old_conf is not None:
_write_text(conf_path, old_conf)
@@ -351,7 +353,11 @@ class WiFiManager:
else:
full_conf = build_sta_conf_open(ssid)
_write_text(conf_path, full_conf)
_write_text(boot_wpa_path, full_conf)
try:
if os.path.exists(boot_wpa_path):
os.remove(boot_wpa_path)
except Exception:
pass
except ValueError as e:
return False, str(e)
except Exception as e:
@@ -542,34 +548,45 @@ class WiFiManager:
network_type_callback: 获取当前网络类型的回调函数
on_poor_quality_callback: WiFi质量差时的回调函数
"""
if self._wifi_quality_monitor_thread is not None:
self.logger.warning("[WiFi Monitor] 监测线程已在运行")
return
with self._wifi_quality_lock:
if self._wifi_quality_monitor_thread is not None and self._wifi_quality_monitor_thread.is_alive():
self.logger.warning("[WiFi Monitor] 监测线程已在运行")
return
self._network_type_callback = network_type_callback
self._on_poor_quality_callback = on_poor_quality_callback
self._wifi_quality_stop_event.clear()
self._wifi_quality_monitor_thread = threading.Thread(
target=self._quality_monitor_loop,
daemon=True,
name="wifi_quality_monitor"
)
self._wifi_quality_monitor_thread.start()
self.logger.info("[WiFi Monitor] 已启动后台监测线程")
self._network_type_callback = network_type_callback
self._on_poor_quality_callback = on_poor_quality_callback
self._wifi_quality_stop_event.clear()
self._wifi_quality_monitor_thread = threading.Thread(
target=self._quality_monitor_loop,
daemon=True,
name="wifi_quality_monitor"
)
self._wifi_quality_monitor_thread.start()
self.logger.info("[WiFi Monitor] 已启动后台监测线程")
def stop_quality_monitor(self):
"""停止 WiFi 质量监测线程"""
if self._wifi_quality_monitor_thread is None:
return
with self._wifi_quality_lock:
t = self._wifi_quality_monitor_thread
if t is None:
return
if not t.is_alive():
self._wifi_quality_monitor_thread = None
return
self._wifi_quality_stop_event.set()
try:
self._wifi_quality_monitor_thread.join(timeout=2.0)
t.join(timeout=2.0)
except Exception as e:
self.logger.error(f"[WiFi Monitor] 停止线程失败:{e}")
finally:
self._wifi_quality_monitor_thread = None
self.logger.info("[WiFi Monitor] 已停止后台监测线程")
with self._wifi_quality_lock:
if t is self._wifi_quality_monitor_thread:
if t.is_alive():
self.logger.warning("[WiFi Monitor] 线程未在超时内退出,保留引用防止重复创建")
else:
self._wifi_quality_monitor_thread = None
self.logger.info("[WiFi Monitor] 已停止后台监测线程")
def _quality_monitor_loop(self):
"""
-267
View File
@@ -1,267 +0,0 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""Standalone live camera + single YOLO runner.
不复用项目内的 `camera_manager` / `target_roi_yolo` / `config` / `logger_manager`
功能
- 独立初始化摄像头
- 实时读取帧
- 独立加载单个 YOLO 模型并推理
- 画出检测框ROIFPS
适用场景
- 单独验证一个模型是否能跑
- 验证实时帧率
- 验证 ROI 是否裁对
- 不进入主业务射箭流程
"""
from __future__ import annotations
import os
import time
from dataclasses import dataclass
@dataclass
class RunnerConfig:
camera_width: int = 640
camera_height: int = 480
model_path: str = "/root/model_278702.mud"
conf_th: float = 0.7
retry_conf_th: float = 0.5
class_ids: tuple = (0,)
merge_mode: str = "union"
coord_mode: str = "native"
roi_margin_frac: float = 0.11
min_box_side_px: int = 8
def log(msg: str):
print(msg)
class DummyLogger:
def info(self, msg):
log(msg)
def warning(self, msg):
log(msg)
def error(self, msg):
log(msg)
class StandaloneYOLORunner:
def __init__(self, cfg: RunnerConfig):
self.cfg = cfg
self.logger = DummyLogger()
self._last_fps_t = time.perf_counter()
self._frames = 0
self._fps = 0.0
self._camera = None
self._det = None
def _import_maix(self):
try:
from maix import camera, image, nn
return camera, image, nn
except Exception as e:
raise RuntimeError(f"maix import failed: {e}")
def _init_camera(self):
camera, _, _ = self._import_maix()
if self._camera is not None:
return self._camera
try:
self._camera = camera.Camera(
width=self.cfg.camera_width,
height=self.cfg.camera_height,
format=camera.RGB888,
)
except Exception:
self._camera = camera.Camera(width=self.cfg.camera_width, height=self.cfg.camera_height)
return self._camera
def _load_detector(self, model_path: str):
_, _, nn = self._import_maix()
if not model_path or not os.path.isfile(model_path):
return None
return nn.YOLOv5(model=model_path, dual_buff=False)
@staticmethod
def _get_class_id(obj):
for key in ("class_id", "cls", "label", "category", "cat_id", "id"):
if hasattr(obj, key):
v = getattr(obj, key)
if v is None:
continue
try:
return int(float(v))
except Exception:
pass
return None
@staticmethod
def _normalize_boxes(raw):
out = []
for o in raw or []:
if isinstance(o, (list, tuple)) and len(o) >= 6:
class Box:
pass
b = Box()
b.x, b.y, b.w, b.h, b.score, b.class_id = map(float, o[:6])
out.append(b)
else:
out.append(o)
return out
def _det_to_xyxy(self, det, obj):
x = float(getattr(obj, "x", 0.0))
y = float(getattr(obj, "y", 0.0))
w = float(getattr(obj, "w", 0.0))
h = float(getattr(obj, "h", 0.0))
return x, y, x + w, y + h
def _run_detector(self, det, img, conf_th, class_ids):
if det is None:
return []
raw = det.detect(img, conf_th=conf_th)
objs = self._normalize_boxes(raw if raw is not None else [])
out = []
for o in objs:
cid = self._get_class_id(o)
if cid is not None and cid not in class_ids:
continue
out.append(o)
return out
def _calc_fps(self):
self._frames += 1
now = time.perf_counter()
dt = now - self._last_fps_t
if dt >= 1.0:
self._fps = self._frames / dt
self._frames = 0
self._last_fps_t = now
return self._fps
def _draw_text(self, img, lines):
try:
import cv2
y = 24
for line in lines:
cv2.putText(img, line, (10, y), cv2.FONT_HERSHEY_SIMPLEX, 0.55, (0, 255, 0), 1, cv2.LINE_AA)
y += 20
except Exception:
pass
def _clip_roi(self, x0, y0, x1, y1, w, h):
x0 = max(0, min(int(x0), w - 1))
y0 = max(0, min(int(y0), h - 1))
x1 = max(x0 + 1, min(int(x1), w))
y1 = max(y0 + 1, min(int(y1), h))
return x0, y0, x1, y1
def _merge_boxes(self, boxes):
if not boxes:
return None
x0 = min(b[0] for b in boxes)
y0 = min(b[1] for b in boxes)
x1 = max(b[2] for b in boxes)
y1 = max(b[3] for b in boxes)
return x0, y0, x1, y1
def _run_single_yolo(self, frame, img_cv):
h, w = int(img_cv.shape[0]), int(img_cv.shape[1])
if self._det is None:
self._det = self._load_detector(self.cfg.model_path)
det = self._det
if det is None:
return []
boxes = self._run_detector(det, frame, self.cfg.conf_th, self.cfg.class_ids)
if not boxes and self.cfg.retry_conf_th < self.cfg.conf_th:
boxes = self._run_detector(det, frame, self.cfg.retry_conf_th, self.cfg.class_ids)
xyxy = []
for obj in boxes:
x0, y0, x1, y1 = self._det_to_xyxy(det, obj)
if (x1 - x0) < self.cfg.min_box_side_px or (y1 - y0) < self.cfg.min_box_side_px:
continue
if self.cfg.coord_mode == "native":
x0, y0, x1, y1 = self._clip_roi(x0, y0, x1, y1, w, h)
xyxy.append((x0, y0, x1, y1))
return xyxy
def run(self):
_, image, _ = self._import_maix()
cam = self._init_camera()
log("[YOLOTE] standalone runner started")
while True:
try:
frame = cam.read()
except Exception as e:
log(f"[YOLOTE] camera read failed: {e}")
time.sleep(0.02)
continue
if frame is None:
time.sleep(0.01)
continue
try:
img_cv = image.image2cv(frame, False, False)
except Exception as e:
log(f"[YOLOTE] image2cv failed: {e}")
time.sleep(0.01)
continue
import cv2
t0 = time.perf_counter()
boxes = self._run_single_yolo(frame, img_cv)
t1 = time.perf_counter()
for i, (bx0, by0, bx1, by1) in enumerate(boxes):
cv2.rectangle(img_cv, (int(bx0), int(by0)), (int(bx1) - 1, int(by1) - 1), (0, 255, 0), 2)
cv2.putText(img_cv, f"B{i}", (int(bx0), max(0, int(by0) - 4)), cv2.FONT_HERSHEY_SIMPLEX, 0.5, (0, 255, 0), 1, cv2.LINE_AA)
fps = self._calc_fps()
self._draw_text(
img_cv,
[
f"FPS: {fps:.1f}",
f"YOLO: {(t1 - t0)*1000.0:.1f} ms",
f"Boxes: {len(boxes)}",
"Ctrl+C to exit",
],
)
try:
frame_out = image.cv2image(img_cv, False, False)
if hasattr(cam, "show"):
cam.show(frame_out)
else:
try:
frame_out.show()
except Exception:
pass
except Exception as e:
log(f"[YOLOTE] show failed: {e}")
time.sleep(0.001)
def main():
cfg = RunnerConfig()
runner = StandaloneYOLORunner(cfg)
try:
runner.run()
except KeyboardInterrupt:
log("[YOLOTE] interrupted")
if __name__ == "__main__":
main()