Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e1f12ae609 | ||
|
|
56fa8fc2a1 | ||
|
|
d3c8a26854 | ||
|
|
eae7da7291 | ||
|
|
dc5da0294f | ||
|
|
165eeff64e | ||
|
|
a184ff7d55 | ||
|
|
054e9e6d90 | ||
|
|
ae339889c2 | ||
|
|
b94b0f2e55 | ||
|
|
e82941a161 | ||
|
|
6556cfcf74 | ||
|
|
abbf30d7c0 | ||
|
|
5cf752bb3f | ||
|
|
6d8de56bfa | ||
|
|
aee1a92760 | ||
|
|
c34efed6f9 | ||
|
|
226394d3ed | ||
|
|
b169618b16 | ||
|
|
5ab4ef2944 | ||
|
|
577ff02c04 | ||
|
|
82d0008257 | ||
|
|
373eeb786a | ||
|
|
4500e62647 | ||
|
|
49a84e80e1 | ||
|
|
9654b79cec | ||
|
|
1ea8c64a40 | ||
|
|
9dd6fef6f8 | ||
|
|
860f9c84c3 | ||
|
|
1a0bfd54f7 | ||
|
|
c46cf5c567 | ||
|
|
0d69a01a1f | ||
|
|
583748fda3 | ||
|
|
d508478c73 | ||
|
|
30c7200a7a | ||
|
|
959635f461 | ||
|
|
86cd8cd46e | ||
|
|
26ed3c1523 | ||
|
|
aa16676c74 | ||
|
|
99614fe321 | ||
|
|
2ad2836d77 | ||
|
|
801453fbdb |
@@ -1,3 +0,0 @@
|
|||||||
/cpp_ext/build/
|
|
||||||
/.cursor/
|
|
||||||
/dist/
|
|
||||||
@@ -1,224 +0,0 @@
|
|||||||
# from maix import app, key, uart, pinmap, time
|
|
||||||
# import hashlib
|
|
||||||
# import hmac
|
|
||||||
# import ujson
|
|
||||||
|
|
||||||
# # 配置 UART2(用于 HTTP 上传)
|
|
||||||
# pinmap.set_pin_function("A29", "UART2_RX")
|
|
||||||
# pinmap.set_pin_function("A28", "UART2_TX")
|
|
||||||
# http_serial = uart.UART("/dev/ttyS2", 115200, uart.BITS.BITS_8,
|
|
||||||
# uart.PARITY.PARITY_NONE, uart.STOP.STOP_1)
|
|
||||||
|
|
||||||
# # 按键初始化
|
|
||||||
# key_triggered = False
|
|
||||||
# def on_key_event(key_id, state):
|
|
||||||
# global key_triggered
|
|
||||||
# if state == key.State.KEY_PRESSED:
|
|
||||||
# key_triggered = True
|
|
||||||
# key_obj = key.Key(on_key_event)
|
|
||||||
|
|
||||||
# # Token生成
|
|
||||||
# def generate_token(device_id):
|
|
||||||
# SALT = "shootMessageFire"
|
|
||||||
# SALT2 = "shoot"
|
|
||||||
# return "Arrow_" + hmac.new((SALT + device_id).encode(), SALT2.encode(), hashlib.sha256).hexdigest()
|
|
||||||
|
|
||||||
# # 发送AT命令
|
|
||||||
# http_instance_id = -1
|
|
||||||
# def send_cmd(cmd_str, expect_create_id=False):
|
|
||||||
# global http_instance_id
|
|
||||||
# print("[AT指令] =>", cmd_str)
|
|
||||||
# http_serial.write((cmd_str + "\r\n").encode())
|
|
||||||
# buffer = b""
|
|
||||||
# start = time.ticks_ms()
|
|
||||||
# while time.ticks_ms() - start < 3000:
|
|
||||||
# data = http_serial.read(128)
|
|
||||||
# if data:
|
|
||||||
# buffer += data
|
|
||||||
# try:
|
|
||||||
# decoded = buffer.decode()
|
|
||||||
# print("返回:", decoded.strip())
|
|
||||||
# if expect_create_id and "+MHTTPCREATE:" in decoded:
|
|
||||||
# http_instance_id = int(decoded.split(":")[1].split("\r")[0].strip())
|
|
||||||
# if "OK" in decoded: return True
|
|
||||||
# if "+CME ERROR" in decoded or "ERROR" in decoded: return False
|
|
||||||
# except:
|
|
||||||
# print("解码异常")
|
|
||||||
# time.sleep_ms(10)
|
|
||||||
# return False
|
|
||||||
|
|
||||||
# def create_http_instance(url):
|
|
||||||
# return send_cmd(f'AT+MHTTPCREATE="{url}"', True) and http_instance_id != -1
|
|
||||||
|
|
||||||
# def send_data(instance_id, token, api_path, json_data):
|
|
||||||
# # 设置Header(使用统一的AT+MHTTPCFG="header")
|
|
||||||
# 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}"')
|
|
||||||
|
|
||||||
# # 发送Body数据
|
|
||||||
# json_str = ujson.dumps(json_data)
|
|
||||||
# send_cmd(f'AT+MHTTPCONTENT={instance_id},0,0,"{json_str}"')
|
|
||||||
# send_cmd(f'AT+MHTTPREQUEST={instance_id},2,0,"{api_path}"')
|
|
||||||
|
|
||||||
# def read_response(timeout_ms=5000):
|
|
||||||
# start = time.ticks_ms()
|
|
||||||
# while time.ticks_ms() - start < timeout_ms:
|
|
||||||
# data = http_serial.read(128)
|
|
||||||
# if data:
|
|
||||||
# try:
|
|
||||||
# print("响应:", data.decode("utf-8").strip())
|
|
||||||
# except:
|
|
||||||
# print("响应(raw):", data)
|
|
||||||
# time.sleep_ms(50)
|
|
||||||
|
|
||||||
# # 参数配置
|
|
||||||
# device_id = "wZhC7kAZ" #需要根据实际硬件ID来测试
|
|
||||||
# url = "http://ws.shelingxingqiu.com"
|
|
||||||
# api_path = "/home/shoot/device_fire/arrow/fire"
|
|
||||||
# token = generate_token(device_id)
|
|
||||||
# print("生成Token:", token)
|
|
||||||
|
|
||||||
# # 主循环:仅监听按键并上传模拟数据
|
|
||||||
# while not app.need_exit():
|
|
||||||
# if key_triggered:
|
|
||||||
# key_triggered = False
|
|
||||||
# print("按键按下,准备上传...")
|
|
||||||
|
|
||||||
# # 模拟数据(可替换为实际测量值)
|
|
||||||
# timestamp = int(time.time() * 1000)
|
|
||||||
# json_data = {
|
|
||||||
# "id": timestamp,
|
|
||||||
# "DeviceId": device_id,
|
|
||||||
# "x": 12.34,
|
|
||||||
# "y": -5.67,
|
|
||||||
# "rag": 90.0,
|
|
||||||
# "time": timestamp,
|
|
||||||
# "dst": 234.5,
|
|
||||||
# "battery": 80,
|
|
||||||
# "errorCode": 0
|
|
||||||
# }
|
|
||||||
|
|
||||||
# if http_instance_id == -1 and not create_http_instance(url):
|
|
||||||
# print("创建 HTTP 实例失败")
|
|
||||||
# elif http_instance_id != -1:
|
|
||||||
# send_data(http_instance_id, token, api_path, json_data)
|
|
||||||
# read_response()
|
|
||||||
# else:
|
|
||||||
# time.sleep_ms(100)
|
|
||||||
from maix import app, uart, pinmap, time
|
|
||||||
import hashlib
|
|
||||||
import hmac
|
|
||||||
import ujson
|
|
||||||
|
|
||||||
# ========== 配置 ==========
|
|
||||||
# UART2 for HTTP
|
|
||||||
pinmap.set_pin_function("A29", "UART2_RX")
|
|
||||||
pinmap.set_pin_function("A28", "UART2_TX")
|
|
||||||
http_serial = uart.UART("/dev/ttyS2", 115200, uart.BITS.BITS_8,
|
|
||||||
uart.PARITY.PARITY_NONE, uart.STOP.STOP_1)
|
|
||||||
|
|
||||||
# 设备参数
|
|
||||||
device_id = "wZhC7kAZ"
|
|
||||||
url = "http://ws.shelingxingqiu.com"
|
|
||||||
api_path = "/home/shoot/device_fire/arrow/fire"
|
|
||||||
|
|
||||||
# ========== 工具函数 ==========
|
|
||||||
def generate_token(device_id):
|
|
||||||
SALT = "shootMessageFire"
|
|
||||||
SALT2 = "shoot"
|
|
||||||
return "Arrow_" + hmac.new((SALT + device_id).encode(), SALT2.encode(), hashlib.sha256).hexdigest()
|
|
||||||
|
|
||||||
def send_cmd(cmd_str, timeout_ms=3000):
|
|
||||||
"""发送 AT 指令并等待 OK / ERROR"""
|
|
||||||
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:
|
|
||||||
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
|
|
||||||
except:
|
|
||||||
pass
|
|
||||||
time.sleep_ms(10)
|
|
||||||
return False
|
|
||||||
|
|
||||||
def create_http_instance(url):
|
|
||||||
cmd = f'AT+MHTTPCREATE="{url}"'
|
|
||||||
if send_cmd(cmd):
|
|
||||||
# 尝试提取 instance ID(如果模块返回)
|
|
||||||
# 注意:部分模块不会返回 ID,可忽略,直接用 0 或 1
|
|
||||||
return True
|
|
||||||
return False
|
|
||||||
|
|
||||||
def send_http_request(url, api_path, token, device_id, json_data):
|
|
||||||
# 1. 创建 HTTP 实例
|
|
||||||
if not create_http_instance(url):
|
|
||||||
print("❌ 创建 HTTP 实例失败")
|
|
||||||
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}"')
|
|
||||||
|
|
||||||
# 3. 发送 Body
|
|
||||||
json_str = ujson.dumps(json_data)
|
|
||||||
send_cmd(f'AT+MHTTPCONTENT={instance_id},0,0,"{json_str}"')
|
|
||||||
|
|
||||||
# 4. 发起 POST 请求
|
|
||||||
if send_cmd(f'AT+MHTTPREQUEST={instance_id},2,0,"{api_path}"'):
|
|
||||||
print("✅ HTTP 请求已发送")
|
|
||||||
return True
|
|
||||||
else:
|
|
||||||
print("❌ 发送请求失败")
|
|
||||||
return False
|
|
||||||
|
|
||||||
def read_response(timeout_ms=5000):
|
|
||||||
print("⏳ 等待响应...")
|
|
||||||
start = time.ticks_ms()
|
|
||||||
while time.ticks_ms() - start < timeout_ms:
|
|
||||||
data = http_serial.read(128)
|
|
||||||
if data:
|
|
||||||
try:
|
|
||||||
print("📡 响应:", data.decode("utf-8").strip())
|
|
||||||
except:
|
|
||||||
print("📡 响应(raw):", data)
|
|
||||||
time.sleep_ms(100)
|
|
||||||
|
|
||||||
# ========== 主程序:直接上传 ==========
|
|
||||||
print("🚀 启动直接上传流程...")
|
|
||||||
|
|
||||||
token = generate_token(device_id)
|
|
||||||
print("🔑 Token:", token)
|
|
||||||
|
|
||||||
# 构造模拟数据
|
|
||||||
timestamp = int(time.time() * 1000)
|
|
||||||
json_data = {
|
|
||||||
"id": timestamp,
|
|
||||||
"DeviceId": device_id,
|
|
||||||
"x": 12.34,
|
|
||||||
"y": -5.67,
|
|
||||||
"rag": 90.0,
|
|
||||||
"time": timestamp,
|
|
||||||
"dst": 234.5,
|
|
||||||
"battery": 80,
|
|
||||||
"errorCode": 0
|
|
||||||
}
|
|
||||||
|
|
||||||
# 执行上传
|
|
||||||
if send_http_request(url, api_path, token, device_id, json_data):
|
|
||||||
read_response()
|
|
||||||
else:
|
|
||||||
print("💥 上传流程失败")
|
|
||||||
|
|
||||||
print("🔚 程序结束")
|
|
||||||
@@ -1,88 +0,0 @@
|
|||||||
from maix import i2c, pinmap, time
|
|
||||||
|
|
||||||
# 配置 I2C1 引脚(请根据实际连接修改)
|
|
||||||
pinmap.set_pin_function("P18", "I2C1_SCL")
|
|
||||||
pinmap.set_pin_function("P21", "I2C1_SDA")
|
|
||||||
|
|
||||||
bus = i2c.I2C(1, i2c.Mode.MASTER)
|
|
||||||
|
|
||||||
INA226_ADDR = 0x40
|
|
||||||
|
|
||||||
# 寄存器定义
|
|
||||||
REG_CONFIGURATION = 0x00
|
|
||||||
REG_BUS_VOLTAGE = 0x02
|
|
||||||
REG_CALIBRATION = 0x05
|
|
||||||
|
|
||||||
# 校准值(不读取电流/功率时也可以省略)
|
|
||||||
CALIBRATION_VALUE = 0x1400
|
|
||||||
|
|
||||||
def write_register(reg, value):
|
|
||||||
data = [(value >> 8) & 0xFF, value & 0xFF]
|
|
||||||
bus.writeto_mem(INA226_ADDR, reg, bytes(data))
|
|
||||||
|
|
||||||
def read_register(reg):
|
|
||||||
data = bus.readfrom_mem(INA226_ADDR, reg, 2)
|
|
||||||
return (data[0] << 8) | data[1]
|
|
||||||
|
|
||||||
def init_ina226():
|
|
||||||
write_register(REG_CONFIGURATION, 0x4527)
|
|
||||||
write_register(REG_CALIBRATION, CALIBRATION_VALUE)
|
|
||||||
|
|
||||||
def get_bus_voltage():
|
|
||||||
raw = read_register(REG_BUS_VOLTAGE)
|
|
||||||
return raw * 1.25 / 1000 # 单位 V
|
|
||||||
|
|
||||||
def voltage_to_percent(voltage):
|
|
||||||
if voltage >= 4.20:
|
|
||||||
return 100
|
|
||||||
elif voltage >= 4.15:
|
|
||||||
return 95
|
|
||||||
elif voltage >= 4.10:
|
|
||||||
return 90
|
|
||||||
elif voltage >= 4.05:
|
|
||||||
return 85
|
|
||||||
elif voltage >= 4.00:
|
|
||||||
return 80
|
|
||||||
elif voltage >= 3.95:
|
|
||||||
return 75
|
|
||||||
elif voltage >= 3.90:
|
|
||||||
return 70
|
|
||||||
elif voltage >= 3.85:
|
|
||||||
return 65
|
|
||||||
elif voltage >= 3.80:
|
|
||||||
return 60
|
|
||||||
elif voltage >= 3.75:
|
|
||||||
return 55
|
|
||||||
elif voltage >= 3.70:
|
|
||||||
return 50
|
|
||||||
elif voltage >= 3.65:
|
|
||||||
return 45
|
|
||||||
elif voltage >= 3.60:
|
|
||||||
return 40
|
|
||||||
elif voltage >= 3.55:
|
|
||||||
return 35
|
|
||||||
elif voltage >= 3.50:
|
|
||||||
return 30
|
|
||||||
elif voltage >= 3.45:
|
|
||||||
return 25
|
|
||||||
elif voltage >= 3.40:
|
|
||||||
return 20
|
|
||||||
elif voltage >= 3.35:
|
|
||||||
return 15
|
|
||||||
elif voltage >= 3.30:
|
|
||||||
return 10
|
|
||||||
elif voltage >= 3.20:
|
|
||||||
return 5
|
|
||||||
else:
|
|
||||||
return 0
|
|
||||||
|
|
||||||
# 初始化 INA226
|
|
||||||
init_ina226()
|
|
||||||
|
|
||||||
# 主循环,只显示电量百分比
|
|
||||||
while True:
|
|
||||||
voltage = get_bus_voltage()
|
|
||||||
battery_percent = voltage_to_percent(voltage)
|
|
||||||
print(f"当前电压: {voltage:.3f} V")
|
|
||||||
print(f"估算电池电量: {battery_percent} %\n")
|
|
||||||
time.sleep(2000)
|
|
||||||
@@ -1,120 +0,0 @@
|
|||||||
|
|
||||||
# 🎯 激光射击系统(双版本)
|
|
||||||
|
|
||||||
适用于 **MaixPy** 平台,支持远程控制、电池监测、Wi-Fi 连接及 OTA 升级。
|
|
||||||
|
|
||||||
提供两个独立实现版本,共享相同网络协议与 OTA 机制,便于统一部署管理:
|
|
||||||
|
|
||||||
- `main.py`:**视觉测距版**
|
|
||||||
- `laser.py`:**激光测距版**
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 📁 项目结构
|
|
||||||
|
|
||||||
```
|
|
||||||
laser_shooting_system/
|
|
||||||
├── README.md
|
|
||||||
├── main.py # 视觉测距版主程序
|
|
||||||
└── laser.py # 激光测距版主程序
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## ⚙️ 硬件依赖
|
|
||||||
|
|
||||||
| 版本 | 必需硬件 |
|
|
||||||
|------------|----------------------------------------|
|
|
||||||
| `main.py` | Maix 系列开发板 + 摄像头 + 其他硬件 |
|
|
||||||
| `laser.py` | Maix 系列开发板 + 激光测距模块(I²C) + 摄像头 + 其他硬件) |
|
|
||||||
|
|
||||||
> 💡 **注意:引脚复用风险**
|
|
||||||
> Maix 开发板部分 GPIO 兼容多协议(如 Wi-Fi / I²C 复用 A15/A27)。
|
|
||||||
> **Wi-Fi 初始化前禁止提前配置 I²C 引脚!**
|
|
||||||
|
|
||||||
### ❗ 关键提示
|
|
||||||
|
|
||||||
| 问题场景 | 后果 |
|
|
||||||
|---------|------|
|
|
||||||
| 提前初始化 I²C | Wi-Fi 初始化失败、OTA 中断、系统重启 |
|
|
||||||
|
|
||||||
✅ **正确做法:**
|
|
||||||
|
|
||||||
- **`main.py`(视觉版)&`laser.py`(激光版)**
|
|
||||||
使用wifi时启用下面代码,注释与WiFi复用的:
|
|
||||||
```python
|
|
||||||
# 以下代码(如有,请启用):
|
|
||||||
# pinmap.set_pin_function("A15", "I2C5_SCL")
|
|
||||||
# pinmap.set_pin_function("A27", "I2C5_SDA")
|
|
||||||
```
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
## 📡 网络通信协议(TCP / JSON)
|
|
||||||
|
|
||||||
设备上电后自动连预设服务器,支持以下指令:
|
|
||||||
|
|
||||||
```json
|
|
||||||
{"data": {"cmd": N, "ssid": "...", "password": "..."}}
|
|
||||||
```
|
|
||||||
|
|
||||||
| `cmd` | 参数 | 功能说明 |
|
|
||||||
|-------|---------------------|------------------------------|
|
|
||||||
| 2 | — | 开启激光校准模式 |
|
|
||||||
| 3 | — | 关闭激光 |
|
|
||||||
| 4 | — | 查询电池电量 & 电压 |
|
|
||||||
| 5 | `ssid`, `password` | 配置 Wi-Fi + 触发 OTA 升级 |
|
|
||||||
| 6 | — | 返回当前 IP 地址 |
|
|
||||||
| 7 | — | 已联网时,直接执行 OTA 下载 |
|
|
||||||
|
|
||||||
### 示例交互
|
|
||||||
|
|
||||||
▶️ 下发指令(服务器 → 设备):
|
|
||||||
```json
|
|
||||||
{"data": {"cmd": 6}}
|
|
||||||
```
|
|
||||||
|
|
||||||
◀️ 设备响应(设备 → 服务器):
|
|
||||||
```json
|
|
||||||
{"result": "current_ip", "ip": "192.168.1.105"}
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 🛠️ 部署步骤
|
|
||||||
|
|
||||||
1. **选择版本**
|
|
||||||
- 固定场景 / 低成本 → `main.py`
|
|
||||||
- 高精度需求 → `laser.py`
|
|
||||||
|
|
||||||
2. **烧录程序**
|
|
||||||
- 将选定文件重命名为 `main.py`,或通过 MaixPy IDE 直接运行
|
|
||||||
|
|
||||||
3. **首次配置 Wi-Fi**
|
|
||||||
- 串口下发,或服务器推送 `cmd=5`:
|
|
||||||
```json
|
|
||||||
{"data": {"cmd": 5, "ssid": "YourWiFi", "password": "12345678"}}
|
|
||||||
```
|
|
||||||
|
|
||||||
4. **后续 OTA 升级**
|
|
||||||
- 确保设备在线后,下发 `cmd=7` 即可触发 OTA
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 📝 注意事项
|
|
||||||
|
|
||||||
- 🔗 **OTA 地址**:由全局变量 `url` 定义,部署前务必修改为实际地址
|
|
||||||
- 🧵 **线程安全**:通过 `update_thread_started` 标志防止 OTA 并发下载
|
|
||||||
- ☀️ **视觉版**:光照敏感,建议在均匀光源环境使用
|
|
||||||
- 📏 **激光版**:确认模块 I²C 地址(默认 `0x29`),避免长线干扰
|
|
||||||
- 🌐 **网络操作**:均在子线程执行,主线程保持实时响应
|
|
||||||
|
|
||||||
## 🔧 打包步骤(命令行)
|
|
||||||
- 以t11的名称打包,或者修改代码升级路径
|
|
||||||
---
|
|
||||||
> 文档版本:v1.2
|
|
||||||
> 更新时间:2025-11-21
|
|
||||||
> 维护人:ZZH
|
|
||||||
```
|
|
||||||
|
|
||||||
-79
@@ -1,79 +0,0 @@
|
|||||||
#!/bin/sh
|
|
||||||
# /etc/init.d/S99archery
|
|
||||||
# 系统启动时处理致命错误恢复(仅处理无法启动的情况)
|
|
||||||
# 注意:应用的启动由系统自动启动机制处理(通过 auto_start.txt)
|
|
||||||
# 功能:
|
|
||||||
# 1. 处理致命错误(无法启动)- 恢复 main.py
|
|
||||||
# 2. 如果重启次数超过阈值,恢复 main.py 并重启系统
|
|
||||||
|
|
||||||
APP_DIR="/maixapp/apps/t11"
|
|
||||||
MAIN_PY="$APP_DIR/main.py"
|
|
||||||
PENDING_FILE="$APP_DIR/ota_pending.json"
|
|
||||||
BACKUP_BASE="$APP_DIR/backups"
|
|
||||||
|
|
||||||
# 进入应用目录
|
|
||||||
cd "$APP_DIR" || exit 0
|
|
||||||
|
|
||||||
# 检查 pending 文件,如果存在且超过重启次数,恢复 main.py(处理致命错误)
|
|
||||||
if [ -f "$PENDING_FILE" ]; then
|
|
||||||
echo "[S99] 检测到 ota_pending.json,检查重启计数..."
|
|
||||||
|
|
||||||
# 尝试从JSON中提取重启计数(使用grep简单提取)
|
|
||||||
RESTART_COUNT=$(cat "$PENDING_FILE" 2>/dev/null | grep -o '"restart_count":[0-9]*' | grep -o '[0-9]*' || echo "0")
|
|
||||||
MAX_RESTARTS=$(cat "$PENDING_FILE" 2>/dev/null | grep -o '"max_restarts":[0-9]*' | grep -o '[0-9]*' || echo "3")
|
|
||||||
|
|
||||||
if [ -n "$RESTART_COUNT" ] && [ "$RESTART_COUNT" -ge "$MAX_RESTARTS" ]; then
|
|
||||||
echo "[S99] 检测到重启次数 ($RESTART_COUNT) 超过阈值 ($MAX_RESTARTS),恢复 main.py..."
|
|
||||||
|
|
||||||
# 尝试从JSON中提取备份目录
|
|
||||||
BACKUP_DIR=$(cat "$PENDING_FILE" 2>/dev/null | grep -o '"backup_dir":"[^"]*"' | grep -o '/[^"]*' || echo "")
|
|
||||||
|
|
||||||
if [ -n "$BACKUP_DIR" ] && [ -f "$BACKUP_DIR/main.py" ]; then
|
|
||||||
# 使用指定的备份目录
|
|
||||||
echo "[S99] 从备份目录恢复: $BACKUP_DIR/main.py"
|
|
||||||
cp "$BACKUP_DIR/main.py" "$MAIN_PY" 2>/dev/null && echo "[S99] 已恢复 main.py"
|
|
||||||
else
|
|
||||||
# 查找最新的备份目录
|
|
||||||
LATEST_BACKUP=$(ls -dt "$BACKUP_BASE"/backup_* 2>/dev/null | head -1)
|
|
||||||
if [ -n "$LATEST_BACKUP" ] && [ -f "$LATEST_BACKUP/main.py" ]; then
|
|
||||||
echo "[S99] 从最新备份恢复: $LATEST_BACKUP/main.py"
|
|
||||||
cp "$LATEST_BACKUP/main.py" "$MAIN_PY" 2>/dev/null && echo "[S99] 已恢复 main.py"
|
|
||||||
else
|
|
||||||
# 如果没有备份目录,尝试使用 main.py.bak
|
|
||||||
if [ -f "$APP_DIR/main.py.bak" ]; then
|
|
||||||
echo "[S99] 从 main.py.bak 恢复"
|
|
||||||
cp "$APP_DIR/main.py.bak" "$MAIN_PY" 2>/dev/null && echo "[S99] 已恢复 main.py"
|
|
||||||
fi
|
|
||||||
fi
|
|
||||||
fi
|
|
||||||
|
|
||||||
# 恢复后重置重启计数,避免循环恢复
|
|
||||||
# 注意:不在这里删除 pending 文件,让 main.py 在心跳成功后删除
|
|
||||||
# 但是重置重启计数,以便恢复后的版本可以重新开始计数
|
|
||||||
python3 -c "
|
|
||||||
import json, os
|
|
||||||
try:
|
|
||||||
pending_path = '$PENDING_FILE'
|
|
||||||
if os.path.exists(pending_path):
|
|
||||||
with open(pending_path, 'r', encoding='utf-8') as f:
|
|
||||||
d = json.load(f)
|
|
||||||
d['restart_count'] = 0 # 重置重启计数
|
|
||||||
with open(pending_path, 'w', encoding='utf-8') as f:
|
|
||||||
json.dump(d, f)
|
|
||||||
print('[S99] 已重置重启计数为 0')
|
|
||||||
except Exception as e:
|
|
||||||
print(f'[S99] 重置重启计数失败: {e}')
|
|
||||||
" 2>/dev/null || echo "[S99] 无法重置重启计数(可能需要Python支持)"
|
|
||||||
|
|
||||||
echo "[S99] 已恢复 main.py,重启系统..."
|
|
||||||
echo "[S99] 注意:pending 文件将在心跳成功后由 main.py 删除"
|
|
||||||
sleep 2
|
|
||||||
reboot
|
|
||||||
exit 0
|
|
||||||
fi
|
|
||||||
fi
|
|
||||||
|
|
||||||
# 不启动应用,让系统自动启动机制处理
|
|
||||||
# 这个脚本只负责处理致命错误恢复
|
|
||||||
exit 0
|
|
||||||
|
|
||||||
@@ -1,16 +0,0 @@
|
|||||||
from maix.peripheral import adc
|
|
||||||
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}")
|
|
||||||
time.sleep_ms(1)
|
|
||||||
|
|
||||||
vol = int(a.read_vol() * 10) / 10
|
|
||||||
print(f"ADC vol:{vol:.1f}, {time.time():.4f}")
|
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
id: t11
|
id: t11
|
||||||
name: t11
|
name: t11
|
||||||
version: 2.14.1
|
version: 2.15.35
|
||||||
author: t11
|
author: t11
|
||||||
icon: ''
|
icon: ''
|
||||||
desc: t11
|
desc: t11
|
||||||
@@ -12,14 +12,15 @@ files:
|
|||||||
- at_client.py
|
- at_client.py
|
||||||
- camera_manager.py
|
- camera_manager.py
|
||||||
- cameraParameters.xml
|
- cameraParameters.xml
|
||||||
|
- charging_exit.sh
|
||||||
- config.py
|
- config.py
|
||||||
- hardware.py
|
- hardware.py
|
||||||
|
- laser_detector.py
|
||||||
- laser_manager.py
|
- laser_manager.py
|
||||||
- logger_manager.py
|
- logger_manager.py
|
||||||
- main.py
|
- main.py
|
||||||
- model_270139.cvimodel
|
|
||||||
- model_270139.mud
|
|
||||||
- network.py
|
- network.py
|
||||||
|
- ota_curl.sh
|
||||||
- ota_manager.py
|
- ota_manager.py
|
||||||
- power.py
|
- power.py
|
||||||
- server.pem
|
- server.pem
|
||||||
|
|||||||
@@ -1,420 +0,0 @@
|
|||||||
#!/usr/bin/env python3
|
|
||||||
# -*- coding: utf-8 -*-
|
|
||||||
"""
|
|
||||||
ArUco标记检测模块
|
|
||||||
提供基于ArUco标记的靶心标定和激光点定位功能
|
|
||||||
"""
|
|
||||||
import cv2
|
|
||||||
import numpy as np
|
|
||||||
import math
|
|
||||||
import config
|
|
||||||
from logger_manager import logger_manager
|
|
||||||
|
|
||||||
|
|
||||||
class ArUcoDetector:
|
|
||||||
"""ArUco标记检测器"""
|
|
||||||
|
|
||||||
def __init__(self):
|
|
||||||
self.logger = logger_manager.logger
|
|
||||||
# 创建ArUco字典和检测器参数
|
|
||||||
self.aruco_dict = cv2.aruco.getPredefinedDictionary(config.ARUCO_DICT_TYPE)
|
|
||||||
self.detector_params = cv2.aruco.DetectorParameters()
|
|
||||||
|
|
||||||
# 设置检测参数
|
|
||||||
self.detector_params.minMarkerPerimeterRate = config.ARUCO_MIN_MARKER_PERIMETER_RATE
|
|
||||||
self.detector_params.cornerRefinementMethod = config.ARUCO_CORNER_REFINEMENT_METHOD
|
|
||||||
|
|
||||||
# 创建检测器
|
|
||||||
self.detector = cv2.aruco.ArucoDetector(self.aruco_dict, self.detector_params)
|
|
||||||
|
|
||||||
# 预定义靶纸上的标记位置(物理坐标,毫米)
|
|
||||||
self.marker_positions_mm = config.ARUCO_MARKER_POSITIONS_MM
|
|
||||||
self.marker_ids = config.ARUCO_MARKER_IDS
|
|
||||||
self.marker_size_mm = config.ARUCO_MARKER_SIZE_MM
|
|
||||||
self.target_paper_size_mm = config.TARGET_PAPER_SIZE_MM
|
|
||||||
|
|
||||||
# 靶心偏移(相对于靶纸中心)
|
|
||||||
self.target_center_offset_mm = config.TARGET_CENTER_OFFSET_MM
|
|
||||||
|
|
||||||
if self.logger:
|
|
||||||
self.logger.info(f"[ARUCO] ArUco检测器初始化完成,字典类型: {config.ARUCO_DICT_TYPE}")
|
|
||||||
|
|
||||||
def detect_markers(self, frame):
|
|
||||||
"""
|
|
||||||
检测图像中的ArUco标记
|
|
||||||
|
|
||||||
Args:
|
|
||||||
frame: MaixPy图像帧对象
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
(corners, ids, rejected) - 检测到的标记角点、ID列表、被拒绝的候选
|
|
||||||
如果检测失败返回 (None, None, None)
|
|
||||||
"""
|
|
||||||
try:
|
|
||||||
# 转换为OpenCV格式
|
|
||||||
from maix import image
|
|
||||||
img_cv = image.image2cv(frame, False, False)
|
|
||||||
|
|
||||||
# 转换为灰度图(ArUco检测需要)
|
|
||||||
if len(img_cv.shape) == 3:
|
|
||||||
gray = cv2.cvtColor(img_cv, cv2.COLOR_RGB2GRAY)
|
|
||||||
else:
|
|
||||||
gray = img_cv
|
|
||||||
|
|
||||||
# 检测标记
|
|
||||||
corners, ids, rejected = self.detector.detectMarkers(gray)
|
|
||||||
|
|
||||||
if self.logger and ids is not None:
|
|
||||||
self.logger.debug(f"[ARUCO] 检测到 {len(ids)} 个标记: {ids.flatten().tolist()}")
|
|
||||||
|
|
||||||
return corners, ids, rejected
|
|
||||||
|
|
||||||
except Exception as e:
|
|
||||||
if self.logger:
|
|
||||||
self.logger.error(f"[ARUCO] 标记检测失败: {e}")
|
|
||||||
return None, None, None
|
|
||||||
|
|
||||||
def get_target_center_from_markers(self, corners, ids):
|
|
||||||
"""
|
|
||||||
从检测到的ArUco标记计算靶心位置
|
|
||||||
|
|
||||||
Args:
|
|
||||||
corners: 标记角点列表
|
|
||||||
ids: 标记ID列表
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
(center_x, center_y, radius, ellipse_params) 或 (None, None, None, None)
|
|
||||||
center_x, center_y: 靶心像素坐标
|
|
||||||
radius: 估计的靶心半径(像素)
|
|
||||||
ellipse_params: 椭圆参数用于透视校正
|
|
||||||
"""
|
|
||||||
if ids is None or len(ids) < 3:
|
|
||||||
if self.logger:
|
|
||||||
self.logger.debug(f"[ARUCO] 检测到的标记数量不足: {len(ids) if ids is not None else 0} < 3")
|
|
||||||
return None, None, None, None
|
|
||||||
|
|
||||||
try:
|
|
||||||
# 将ID转换为列表便于查找
|
|
||||||
detected_ids = ids.flatten().tolist()
|
|
||||||
|
|
||||||
# 收集检测到的标记中心点和对应的物理坐标
|
|
||||||
image_points = [] # 图像坐标 (像素)
|
|
||||||
object_points = [] # 物理坐标 (毫米)
|
|
||||||
marker_centers = {} # 存储每个标记的中心
|
|
||||||
|
|
||||||
for i, marker_id in enumerate(detected_ids):
|
|
||||||
if marker_id not in self.marker_ids:
|
|
||||||
continue
|
|
||||||
|
|
||||||
# 计算标记中心(四个角的平均值)
|
|
||||||
corner = corners[i][0] # shape: (4, 2)
|
|
||||||
center_x = np.mean(corner[:, 0])
|
|
||||||
center_y = np.mean(corner[:, 1])
|
|
||||||
marker_centers[marker_id] = (center_x, center_y)
|
|
||||||
|
|
||||||
# 添加到点列表
|
|
||||||
image_points.append([center_x, center_y])
|
|
||||||
object_points.append(self.marker_positions_mm[marker_id])
|
|
||||||
|
|
||||||
if len(image_points) < 3:
|
|
||||||
if self.logger:
|
|
||||||
self.logger.debug(f"[ARUCO] 有效标记数量不足: {len(image_points)} < 3")
|
|
||||||
return None, None, None, None
|
|
||||||
|
|
||||||
# 转换为numpy数组
|
|
||||||
image_points = np.array(image_points, dtype=np.float32)
|
|
||||||
object_points = np.array(object_points, dtype=np.float32)
|
|
||||||
|
|
||||||
# 计算单应性矩阵(Homography)
|
|
||||||
# 这建立了物理坐标到图像坐标的映射
|
|
||||||
H, status = cv2.findHomography(object_points, image_points, cv2.RANSAC, 5.0)
|
|
||||||
|
|
||||||
if H is None:
|
|
||||||
if self.logger:
|
|
||||||
self.logger.warning("[ARUCO] 无法计算单应性矩阵")
|
|
||||||
return None, None, None, None
|
|
||||||
|
|
||||||
# 计算靶心在图像中的位置
|
|
||||||
# 靶心物理坐标 = 靶纸中心 + 偏移
|
|
||||||
target_center_mm = np.array([[self.target_center_offset_mm[0],
|
|
||||||
self.target_center_offset_mm[1]]], dtype=np.float32)
|
|
||||||
target_center_mm = target_center_mm.reshape(-1, 1, 2)
|
|
||||||
|
|
||||||
# 使用单应性矩阵投影到图像坐标
|
|
||||||
target_center_img = cv2.perspectiveTransform(target_center_mm, H)
|
|
||||||
center_x = target_center_img[0][0][0]
|
|
||||||
center_y = target_center_img[0][0][1]
|
|
||||||
|
|
||||||
# 计算靶心半径(像素)
|
|
||||||
# 使用已知物理距离和像素距离的比例
|
|
||||||
# 选择两个标记计算比例尺
|
|
||||||
if len(marker_centers) >= 2:
|
|
||||||
# 使用对角线上的标记计算比例尺
|
|
||||||
if 0 in marker_centers and 2 in marker_centers:
|
|
||||||
p1_img = np.array(marker_centers[0])
|
|
||||||
p2_img = np.array(marker_centers[2])
|
|
||||||
p1_mm = np.array(self.marker_positions_mm[0])
|
|
||||||
p2_mm = np.array(self.marker_positions_mm[2])
|
|
||||||
elif 1 in marker_centers and 3 in marker_centers:
|
|
||||||
p1_img = np.array(marker_centers[1])
|
|
||||||
p2_img = np.array(marker_centers[3])
|
|
||||||
p1_mm = np.array(self.marker_positions_mm[1])
|
|
||||||
p2_mm = np.array(self.marker_positions_mm[3])
|
|
||||||
else:
|
|
||||||
# 使用任意两个标记
|
|
||||||
keys = list(marker_centers.keys())
|
|
||||||
p1_img = np.array(marker_centers[keys[0]])
|
|
||||||
p2_img = np.array(marker_centers[keys[1]])
|
|
||||||
p1_mm = np.array(self.marker_positions_mm[keys[0]])
|
|
||||||
p2_mm = np.array(self.marker_positions_mm[keys[1]])
|
|
||||||
|
|
||||||
pixel_distance = np.linalg.norm(p1_img - p2_img)
|
|
||||||
mm_distance = np.linalg.norm(p1_mm - p2_mm)
|
|
||||||
|
|
||||||
if mm_distance > 0:
|
|
||||||
pixels_per_mm = pixel_distance / mm_distance
|
|
||||||
# 标准靶心半径:10环半径约1.22cm = 12.2mm
|
|
||||||
# 但这里我们返回一个估计值,实际环数计算在laser_manager中
|
|
||||||
radius_mm = 122.0 # 整个靶纸的半径约200mm,但靶心区域较小
|
|
||||||
radius = int(radius_mm * pixels_per_mm)
|
|
||||||
else:
|
|
||||||
radius = 100 # 默认值
|
|
||||||
else:
|
|
||||||
radius = 100 # 默认值
|
|
||||||
|
|
||||||
# 计算椭圆参数(用于透视校正)
|
|
||||||
# 从单应性矩阵可以推导出透视变形
|
|
||||||
ellipse_params = self._compute_ellipse_params(H, center_x, center_y)
|
|
||||||
|
|
||||||
if self.logger:
|
|
||||||
self.logger.info(f"[ARUCO] 靶心计算成功: 中心=({center_x:.1f}, {center_y:.1f}), "
|
|
||||||
f"半径={radius}px, 检测到{len(marker_centers)}个标记")
|
|
||||||
|
|
||||||
return (int(center_x), int(center_y)), radius, "aruco", ellipse_params
|
|
||||||
|
|
||||||
except Exception as e:
|
|
||||||
if self.logger:
|
|
||||||
self.logger.error(f"[ARUCO] 计算靶心失败: {e}")
|
|
||||||
import traceback
|
|
||||||
self.logger.error(traceback.format_exc())
|
|
||||||
return None, None, None, None
|
|
||||||
|
|
||||||
def _compute_ellipse_params(self, H, center_x, center_y):
|
|
||||||
"""
|
|
||||||
从单应性矩阵计算椭圆参数,用于透视校正
|
|
||||||
|
|
||||||
Args:
|
|
||||||
H: 单应性矩阵 (3x3)
|
|
||||||
center_x, center_y: 靶心图像坐标
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
ellipse_params: ((center_x, center_y), (width, height), angle)
|
|
||||||
"""
|
|
||||||
try:
|
|
||||||
# 在物理坐标系中画一个圆,投影到图像中看变成什么形状
|
|
||||||
# 物理圆:半径10mm
|
|
||||||
r_mm = 10.0
|
|
||||||
angles = np.linspace(0, 2*np.pi, 16)
|
|
||||||
circle_mm = np.array([[self.target_center_offset_mm[0] + r_mm * np.cos(a),
|
|
||||||
self.target_center_offset_mm[1] + r_mm * np.sin(a)]
|
|
||||||
for a in angles], dtype=np.float32)
|
|
||||||
circle_mm = circle_mm.reshape(-1, 1, 2)
|
|
||||||
|
|
||||||
# 投影到图像
|
|
||||||
circle_img = cv2.perspectiveTransform(circle_mm, H)
|
|
||||||
circle_img = circle_img.reshape(-1, 2)
|
|
||||||
|
|
||||||
# 拟合椭圆
|
|
||||||
if len(circle_img) >= 5:
|
|
||||||
ellipse = cv2.fitEllipse(circle_img.astype(np.float32))
|
|
||||||
return ellipse
|
|
||||||
else:
|
|
||||||
# 从单应性矩阵近似估计
|
|
||||||
# 提取缩放和旋转
|
|
||||||
# H = K * [R|t] 的近似
|
|
||||||
# 这里简化处理:假设没有严重变形
|
|
||||||
scale_x = np.linalg.norm(H[0, :2])
|
|
||||||
scale_y = np.linalg.norm(H[1, :2])
|
|
||||||
avg_scale = (scale_x + scale_y) / 2
|
|
||||||
|
|
||||||
width = r_mm * 2 * scale_x
|
|
||||||
height = r_mm * 2 * scale_y
|
|
||||||
angle = np.degrees(np.arctan2(H[1, 0], H[0, 0]))
|
|
||||||
|
|
||||||
return ((center_x, center_y), (width, height), angle)
|
|
||||||
|
|
||||||
except Exception as e:
|
|
||||||
if self.logger:
|
|
||||||
self.logger.debug(f"[ARUCO] 计算椭圆参数失败: {e}")
|
|
||||||
return None
|
|
||||||
|
|
||||||
def transform_laser_point(self, laser_point, corners, ids):
|
|
||||||
"""
|
|
||||||
将激光点从图像坐标转换到物理坐标(毫米),再计算相对于靶心的偏移
|
|
||||||
|
|
||||||
Args:
|
|
||||||
laser_point: (x, y) 激光点在图像中的坐标
|
|
||||||
corners: 检测到的标记角点
|
|
||||||
ids: 检测到的标记ID
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
(dx_mm, dy_mm) 激光点相对于靶心的偏移(毫米),或 (None, None)
|
|
||||||
"""
|
|
||||||
if laser_point is None or ids is None or len(ids) < 3:
|
|
||||||
return None, None
|
|
||||||
|
|
||||||
try:
|
|
||||||
# 重新计算单应性矩阵(可以优化为缓存)
|
|
||||||
detected_ids = ids.flatten().tolist()
|
|
||||||
image_points = []
|
|
||||||
object_points = []
|
|
||||||
|
|
||||||
for i, marker_id in enumerate(detected_ids):
|
|
||||||
if marker_id not in self.marker_ids:
|
|
||||||
continue
|
|
||||||
corner = corners[i][0]
|
|
||||||
center_x = np.mean(corner[:, 0])
|
|
||||||
center_y = np.mean(corner[:, 1])
|
|
||||||
image_points.append([center_x, center_y])
|
|
||||||
object_points.append(self.marker_positions_mm[marker_id])
|
|
||||||
|
|
||||||
if len(image_points) < 3:
|
|
||||||
return None, None
|
|
||||||
|
|
||||||
image_points = np.array(image_points, dtype=np.float32)
|
|
||||||
object_points = np.array(object_points, dtype=np.float32)
|
|
||||||
|
|
||||||
H, _ = cv2.findHomography(object_points, image_points, cv2.RANSAC, 5.0)
|
|
||||||
if H is None:
|
|
||||||
return None, None
|
|
||||||
|
|
||||||
# 求逆矩阵,将图像坐标转换到物理坐标
|
|
||||||
H_inv = np.linalg.inv(H)
|
|
||||||
|
|
||||||
laser_img = np.array([[laser_point[0], laser_point[1]]], dtype=np.float32)
|
|
||||||
laser_img = laser_img.reshape(-1, 1, 2)
|
|
||||||
|
|
||||||
laser_mm = cv2.perspectiveTransform(laser_img, H_inv)
|
|
||||||
laser_x_mm = laser_mm[0][0][0]
|
|
||||||
laser_y_mm = laser_mm[0][0][1]
|
|
||||||
|
|
||||||
# 计算相对于靶心的偏移
|
|
||||||
# 注意:Y轴方向可能需要翻转(图像Y向下,物理Y通常向上)
|
|
||||||
dx_mm = laser_x_mm - self.target_center_offset_mm[0]
|
|
||||||
dy_mm = -(laser_y_mm - self.target_center_offset_mm[1]) # 翻转Y轴
|
|
||||||
|
|
||||||
if self.logger:
|
|
||||||
self.logger.debug(f"[ARUCO] 激光点转换: 图像({laser_point[0]:.1f}, {laser_point[1]:.1f}) -> "
|
|
||||||
f"物理({laser_x_mm:.1f}, {laser_y_mm:.1f}) -> "
|
|
||||||
f"偏移({dx_mm:.1f}, {dy_mm:.1f})mm")
|
|
||||||
|
|
||||||
return dx_mm, dy_mm
|
|
||||||
|
|
||||||
except Exception as e:
|
|
||||||
if self.logger:
|
|
||||||
self.logger.error(f"[ARUCO] 激光点转换失败: {e}")
|
|
||||||
return None, None
|
|
||||||
|
|
||||||
def draw_debug_info(self, frame, corners, ids, target_center=None, laser_point=None):
|
|
||||||
"""
|
|
||||||
在图像上绘制调试信息
|
|
||||||
|
|
||||||
Args:
|
|
||||||
frame: MaixPy图像帧
|
|
||||||
corners: 标记角点
|
|
||||||
ids: 标记ID
|
|
||||||
target_center: 计算的靶心位置
|
|
||||||
laser_point: 激光点位置
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
绘制后的图像
|
|
||||||
"""
|
|
||||||
try:
|
|
||||||
from maix import image
|
|
||||||
img_cv = image.image2cv(frame, False, False).copy()
|
|
||||||
|
|
||||||
# 绘制检测到的标记
|
|
||||||
if ids is not None:
|
|
||||||
cv2.aruco.drawDetectedMarkers(img_cv, corners, ids)
|
|
||||||
|
|
||||||
# 绘制标记ID和中心
|
|
||||||
for i, marker_id in enumerate(ids.flatten()):
|
|
||||||
corner = corners[i][0]
|
|
||||||
center_x = int(np.mean(corner[:, 0]))
|
|
||||||
center_y = int(np.mean(corner[:, 1]))
|
|
||||||
|
|
||||||
# 绘制中心点
|
|
||||||
cv2.circle(img_cv, (center_x, center_y), 5, (0, 255, 0), -1)
|
|
||||||
|
|
||||||
# 绘制ID
|
|
||||||
cv2.putText(img_cv, f"ID:{marker_id}",
|
|
||||||
(center_x + 10, center_y - 10),
|
|
||||||
cv2.FONT_HERSHEY_SIMPLEX, 0.6, (0, 255, 0), 2)
|
|
||||||
|
|
||||||
# 绘制靶心
|
|
||||||
if target_center:
|
|
||||||
cv2.circle(img_cv, target_center, 8, (255, 0, 0), -1)
|
|
||||||
cv2.circle(img_cv, target_center, 50, (255, 0, 0), 2)
|
|
||||||
cv2.putText(img_cv, "TARGET", (target_center[0] + 15, target_center[1] - 15),
|
|
||||||
cv2.FONT_HERSHEY_SIMPLEX, 0.7, (255, 0, 0), 2)
|
|
||||||
|
|
||||||
# 绘制激光点
|
|
||||||
if laser_point:
|
|
||||||
cv2.circle(img_cv, (int(laser_point[0]), int(laser_point[1])), 6, (0, 0, 255), -1)
|
|
||||||
cv2.putText(img_cv, "LASER", (int(laser_point[0]) + 10, int(laser_point[1]) - 10),
|
|
||||||
cv2.FONT_HERSHEY_SIMPLEX, 0.6, (0, 0, 255), 2)
|
|
||||||
|
|
||||||
# 转换回MaixPy图像
|
|
||||||
return image.cv2image(img_cv, False, False)
|
|
||||||
|
|
||||||
except Exception as e:
|
|
||||||
if self.logger:
|
|
||||||
self.logger.error(f"[ARUCO] 绘制调试信息失败: {e}")
|
|
||||||
return frame
|
|
||||||
|
|
||||||
|
|
||||||
# 创建全局单例实例
|
|
||||||
aruco_detector = ArUcoDetector()
|
|
||||||
|
|
||||||
|
|
||||||
def detect_target_with_aruco(frame, laser_point=None):
|
|
||||||
"""
|
|
||||||
使用ArUco标记检测靶心的便捷函数
|
|
||||||
|
|
||||||
Args:
|
|
||||||
frame: MaixPy图像帧
|
|
||||||
laser_point: 激光点坐标(可选)
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
(result_img, center, radius, method, best_radius1, ellipse_params)
|
|
||||||
与detect_circle_v3保持相同的返回格式
|
|
||||||
"""
|
|
||||||
detector = aruco_detector
|
|
||||||
|
|
||||||
# 检测ArUco标记
|
|
||||||
corners, ids, rejected = detector.detect_markers(frame)
|
|
||||||
|
|
||||||
# 计算靶心
|
|
||||||
center, radius, method, ellipse_params = detector.get_target_center_from_markers(corners, ids)
|
|
||||||
|
|
||||||
# 绘制调试信息
|
|
||||||
result_img = detector.draw_debug_info(frame, corners, ids, center, laser_point)
|
|
||||||
|
|
||||||
# 返回与detect_circle_v3相同的格式
|
|
||||||
# best_radius1用于距离估算,这里用radius代替
|
|
||||||
return result_img, center, radius, method, radius, ellipse_params
|
|
||||||
|
|
||||||
|
|
||||||
def compute_laser_offset_aruco(laser_point, corners, ids):
|
|
||||||
"""
|
|
||||||
使用ArUco计算激光点相对于靶心的偏移(毫米)
|
|
||||||
|
|
||||||
Args:
|
|
||||||
laser_point: (x, y) 激光点图像坐标
|
|
||||||
corners: ArUco标记角点
|
|
||||||
ids: ArUco标记ID
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
(dx_mm, dy_mm) 偏移量(毫米),或 (None, None)
|
|
||||||
"""
|
|
||||||
return aruco_detector.transform_laser_point(laser_point, corners, ids)
|
|
||||||
+44
-7
@@ -69,17 +69,18 @@ class ATClient:
|
|||||||
# 同上:避免在 _reader_loop 持锁期间二次 acquire
|
# 同上:避免在 _reader_loop 持锁期间二次 acquire
|
||||||
self._http_events.append(ev)
|
self._http_events.append(ev)
|
||||||
|
|
||||||
def send(self, cmd: str, expect: str = "OK", timeout_ms: int = 2000):
|
def send(self, cmd: str, expect: str = "OK", timeout_ms: int = 2000, abort_event=None):
|
||||||
"""
|
"""
|
||||||
发送 AT 命令并等待 expect(子串匹配)。
|
发送 AT 命令并等待 expect(子串匹配)。
|
||||||
注意:expect=">" 用于等待 prompt。
|
注意:expect=">" 用于等待 prompt。
|
||||||
"""
|
"""
|
||||||
expect_b = expect.encode() if isinstance(expect, str) else expect
|
expect_b = expect.encode() if isinstance(expect, str) else expect
|
||||||
with self._cmd_lock:
|
with self._cmd_lock:
|
||||||
# 初始化等待
|
with self._q_lock:
|
||||||
self._waiting = True
|
# 初始化等待
|
||||||
self._expect = expect_b
|
self._waiting = True
|
||||||
self._resp = b""
|
self._expect = expect_b
|
||||||
|
self._resp = b""
|
||||||
|
|
||||||
# 发送
|
# 发送
|
||||||
if cmd:
|
if cmd:
|
||||||
@@ -89,6 +90,9 @@ class ATClient:
|
|||||||
|
|
||||||
t0 = time.ticks_ms()
|
t0 = time.ticks_ms()
|
||||||
while abs(time.ticks_diff(time.ticks_ms(), t0)) < timeout_ms:
|
while abs(time.ticks_diff(time.ticks_ms(), t0)) < timeout_ms:
|
||||||
|
if abort_event is not None and abort_event.is_set():
|
||||||
|
self._waiting = False
|
||||||
|
break
|
||||||
if (not self._waiting) or (self._expect in self._resp):
|
if (not self._waiting) or (self._expect in self._resp):
|
||||||
self._waiting = False
|
self._waiting = False
|
||||||
break
|
break
|
||||||
@@ -101,6 +105,39 @@ class ATClient:
|
|||||||
except:
|
except:
|
||||||
return str(self._resp)
|
return str(self._resp)
|
||||||
|
|
||||||
|
def send_raw_and_wait(self, data: bytes, expect: str = "OK", timeout_ms: int = 1000,
|
||||||
|
suffix: bytes = b""):
|
||||||
|
"""Register the response waiter before writing raw UART data."""
|
||||||
|
expect_b = expect.encode() if isinstance(expect, str) else expect
|
||||||
|
with self._cmd_lock:
|
||||||
|
with self._q_lock:
|
||||||
|
self._waiting = True
|
||||||
|
self._expect = expect_b
|
||||||
|
self._resp = b""
|
||||||
|
|
||||||
|
total = 0
|
||||||
|
while total < len(data):
|
||||||
|
n = self.uart.write(data[total:])
|
||||||
|
if not n or n < 0:
|
||||||
|
time.sleep_ms(1)
|
||||||
|
continue
|
||||||
|
total += n
|
||||||
|
if suffix:
|
||||||
|
self.uart.write(suffix)
|
||||||
|
|
||||||
|
t0 = time.ticks_ms()
|
||||||
|
while abs(time.ticks_diff(time.ticks_ms(), t0)) < timeout_ms:
|
||||||
|
if (not self._waiting) or (self._expect in self._resp):
|
||||||
|
self._waiting = False
|
||||||
|
break
|
||||||
|
time.sleep_ms(5)
|
||||||
|
|
||||||
|
self._waiting = False
|
||||||
|
try:
|
||||||
|
return self._resp.decode(errors="ignore")
|
||||||
|
except:
|
||||||
|
return str(self._resp)
|
||||||
|
|
||||||
def _find_urc_tag(self, tag: bytes):
|
def _find_urc_tag(self, tag: bytes):
|
||||||
"""
|
"""
|
||||||
只在"真正的 URC 边界"查找 tag,避免误命中 HTTP payload 内容。
|
只在"真正的 URC 边界"查找 tag,避免误命中 HTTP payload 内容。
|
||||||
@@ -300,8 +337,8 @@ class ATClient:
|
|||||||
if len(self._rx) > 512 * 1024:
|
if len(self._rx) > 512 * 1024:
|
||||||
self._rx = self._rx[-256 * 1024:]
|
self._rx = self._rx[-256 * 1024:]
|
||||||
else:
|
else:
|
||||||
if len(self._rx) > 16384:
|
if len(self._rx) > 32768:
|
||||||
self._rx = self._rx[-4096:]
|
self._rx = self._rx[-16384:]
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,47 @@
|
|||||||
|
#!/bin/sh
|
||||||
|
|
||||||
|
# The application supplies its own PID. Refuse broad or malformed targets.
|
||||||
|
TARGET_PID="$1"
|
||||||
|
LASER_DEVICE="${2:-/dev/ttyS1}"
|
||||||
|
LASER_BAUD="${3:-9600}"
|
||||||
|
|
||||||
|
turn_off_laser() {
|
||||||
|
if [ ! -c "$LASER_DEVICE" ]; then
|
||||||
|
echo "[CHARGE] laser serial device not found: $LASER_DEVICE" >&2
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
stty -F "$LASER_DEVICE" "$LASER_BAUD" raw -echo 2>/dev/null || return 1
|
||||||
|
printf '\252\000\001\276\000\001\000\000\300' > "$LASER_DEVICE"
|
||||||
|
}
|
||||||
|
|
||||||
|
case "$TARGET_PID" in
|
||||||
|
''|*[!0-9]*)
|
||||||
|
echo "[CHARGE] invalid application pid: $TARGET_PID" >&2
|
||||||
|
exit 2
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
|
||||||
|
if [ "$TARGET_PID" -le 1 ]; then
|
||||||
|
echo "[CHARGE] refusing to terminate pid: $TARGET_PID" >&2
|
||||||
|
exit 2
|
||||||
|
fi
|
||||||
|
|
||||||
|
# First request laser-off while the application still owns the initialized UART.
|
||||||
|
turn_off_laser || true
|
||||||
|
|
||||||
|
kill -TERM "$TARGET_PID" 2>/dev/null || true
|
||||||
|
|
||||||
|
# Wait up to two seconds for a graceful exit, then force termination.
|
||||||
|
WAIT_COUNT=0
|
||||||
|
while kill -0 "$TARGET_PID" 2>/dev/null && [ "$WAIT_COUNT" -lt 20 ]; do
|
||||||
|
sleep 0.1
|
||||||
|
WAIT_COUNT=$((WAIT_COUNT + 1))
|
||||||
|
done
|
||||||
|
if kill -0 "$TARGET_PID" 2>/dev/null; then
|
||||||
|
kill -KILL "$TARGET_PID" 2>/dev/null || true
|
||||||
|
sleep 0.1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Send laser-off again after the application releases the UART.
|
||||||
|
turn_off_laser || true
|
||||||
@@ -24,7 +24,7 @@ TRIANGLE_DETECT_SCALE = 0.4
|
|||||||
# SERVER_IP = "stcp.shelingxingqiu.com"
|
# SERVER_IP = "stcp.shelingxingqiu.com"
|
||||||
SERVER_IP = "www.shelingxingqiu.com"
|
SERVER_IP = "www.shelingxingqiu.com"
|
||||||
SERVER_PORT = 50005
|
SERVER_PORT = 50005
|
||||||
HEARTBEAT_INTERVAL = 15 # 心跳间隔(秒)
|
HEARTBEAT_INTERVAL = 5 # 心跳间隔(秒)
|
||||||
|
|
||||||
# WiFi 质量评估(开机先尝试 WiFi;质量差且 4G 可用则切到 4G,本次上电直至关机锁定 4G)
|
# WiFi 质量评估(开机先尝试 WiFi;质量差且 4G 可用则切到 4G,本次上电直至关机锁定 4G)
|
||||||
WIFI_QUALITY_RTT_SAMPLES = 3 # 到业务服务器 TCP 建连耗时采样次数,取中位数
|
WIFI_QUALITY_RTT_SAMPLES = 3 # 到业务服务器 TCP 建连耗时采样次数,取中位数
|
||||||
@@ -134,7 +134,7 @@ IMAGE_CENTER_Y = 240 # 图像中心 Y 坐标
|
|||||||
# ==================== 三角形四角标记:单应性偏移 + PnP 估距 ====================
|
# ==================== 三角形四角标记:单应性偏移 + PnP 估距 ====================
|
||||||
# 依赖 cameraParameters.xml(相机内参)与 triangle_positions.json(四角物方坐标,厘米或毫米见 JSON 约定)。
|
# 依赖 cameraParameters.xml(相机内参)与 triangle_positions.json(四角物方坐标,厘米或毫米见 JSON 约定)。
|
||||||
# 部署时请把这两个文件放到 APP_DIR(与 main 同应用目录),或改下面路径为设备上的实际绝对路径。
|
# 部署时请把这两个文件放到 APP_DIR(与 main 同应用目录),或改下面路径为设备上的实际绝对路径。
|
||||||
USE_TRIANGLE_OFFSET = True # False 时仅走黄心圆/椭圆 + 半径估距,不使用三角形路径
|
USE_TRIANGLE_OFFSET = False # False 时仅走黄心圆/椭圆 + 半径估距,不使用三角形路径
|
||||||
CAMERA_CALIB_XML = APP_DIR + "/cameraParameters.xml"
|
CAMERA_CALIB_XML = APP_DIR + "/cameraParameters.xml"
|
||||||
TRIANGLE_POSITIONS_JSON = APP_DIR + "/triangle_positions.json"
|
TRIANGLE_POSITIONS_JSON = APP_DIR + "/triangle_positions.json"
|
||||||
# 检测到的三角形边长在图像中的像素范围,分辨率或靶纸占比变化时可微调
|
# 检测到的三角形边长在图像中的像素范围,分辨率或靶纸占比变化时可微调
|
||||||
@@ -260,7 +260,7 @@ TRIANGLE_SAMPLE_RADIUS_CM = 15.0
|
|||||||
TRIANGLE_SAMPLE_ANGLES_DEG = (0, 90, 180, 270)
|
TRIANGLE_SAMPLE_ANGLES_DEG = (0, 90, 180, 270)
|
||||||
TRIANGLE_SAMPLE_PATCH_HALF_PX = 2
|
TRIANGLE_SAMPLE_PATCH_HALF_PX = 2
|
||||||
# 开机阶段预加载 YOLO detector;detect 使用 dual_buff=False,避免返回上一帧结果。
|
# 开机阶段预加载 YOLO detector;detect 使用 dual_buff=False,避免返回上一帧结果。
|
||||||
TRIANGLE_YOLO_PRELOAD_ON_BOOT = True
|
TRIANGLE_YOLO_PRELOAD_ON_BOOT = False
|
||||||
|
|
||||||
# ── 第二段 YOLO:仅在 Stage1 裁切出的靶环图上推理(与合成 stage2 训练数据一致)→ 子框内传统算法取直角点 ──
|
# ── 第二段 YOLO:仅在 Stage1 裁切出的靶环图上推理(与合成 stage2 训练数据一致)→ 子框内传统算法取直角点 ──
|
||||||
# Stage1 靶环裁切内如何找黑三角标记(对比耗时时可切换):
|
# Stage1 靶环裁切内如何找黑三角标记(对比耗时时可切换):
|
||||||
@@ -308,8 +308,15 @@ LASER_COLOR = (0, 255, 0) # RGB颜色
|
|||||||
LASER_THICKNESS = 1
|
LASER_THICKNESS = 1
|
||||||
LASER_LENGTH = 2
|
LASER_LENGTH = 2
|
||||||
|
|
||||||
|
# ==================== 队列大小限制(防止内存泄漏) ====================
|
||||||
|
MAX_SEND_QUEUE_SIZE = 500 # 发送队列上限
|
||||||
|
MAX_TCP_PAYLOADS = 500 # AT TCP 载荷缓存上限
|
||||||
|
MAX_HTTP_EVENTS = 200 # AT HTTP 事件缓存上限
|
||||||
|
LOG_QUEUE_MAXSIZE = 10000 # 日志队列上限
|
||||||
|
MAX_CMD_THREADS = 10 # 并发命令线程上限(防止服务器下发命令时无限创建线程)
|
||||||
|
|
||||||
# ==================== 图像保存配置 ====================
|
# ==================== 图像保存配置 ====================
|
||||||
SAVE_IMAGE_ENABLED = True # 是否保存图像(True=保存,False=不保存)
|
SAVE_IMAGE_ENABLED = False # 是否保存图像(True=保存,False=不保存)
|
||||||
PHOTO_DIR = "/root/phot" # 照片存储目录
|
PHOTO_DIR = "/root/phot" # 照片存储目录
|
||||||
MAX_IMAGES = 1000
|
MAX_IMAGES = 1000
|
||||||
# Stage2 调试目录(默认 PHOTO_DIR/stage2_roi)内 JPEG 最多保留张数;None 表示与 MAX_IMAGES 相同
|
# Stage2 调试目录(默认 PHOTO_DIR/stage2_roi)内 JPEG 最多保留张数;None 表示与 MAX_IMAGES 相同
|
||||||
@@ -336,6 +343,18 @@ PIN_MAPPINGS = {
|
|||||||
# ==================== 电源配置 ====================
|
# ==================== 电源配置 ====================
|
||||||
AUTO_POWER_OFF_IN_SECONDS = 10 * 60 # 自动关机时间(秒),0表示不自动关机
|
AUTO_POWER_OFF_IN_SECONDS = 10 * 60 # 自动关机时间(秒),0表示不自动关机
|
||||||
|
|
||||||
|
# 实机数据:正常放电约为正电流,插入充电线后约为负电流。
|
||||||
|
CHARGING_SHUTDOWN_ENABLED = True # True=充电时退出应用,False=关闭充电关机功能
|
||||||
|
CHARGING_DIAGNOSTIC_LOG_ENABLED = False
|
||||||
|
CHARGING_CHECK_INTERVAL_MS = 3000
|
||||||
|
CHARGING_CURRENT_THRESHOLD_MA = 100.0
|
||||||
|
CHARGING_CONFIRM_COUNT = 2
|
||||||
|
CHARGING_NOTIFY_TIMEOUT_MS = 30000
|
||||||
|
CHARGING_4G_UART_LOCK_TIMEOUT_SEC = 2.5
|
||||||
|
CHARGING_4G_PROMPT_TIMEOUT_MS = 1500
|
||||||
|
CHARGING_4G_CONFIRM_TIMEOUT_MS = 1000
|
||||||
|
CHARGING_EXIT_SCRIPT = APP_DIR + "/charging_exit.sh"
|
||||||
|
|
||||||
BATTERY_SOC_LPF_ALPHA = 0.5
|
BATTERY_SOC_LPF_ALPHA = 0.5
|
||||||
BATTERY_SOC_AVG_WINDOW = 5
|
BATTERY_SOC_AVG_WINDOW = 5
|
||||||
|
|
||||||
|
|||||||
@@ -1,72 +0,0 @@
|
|||||||
cmake_minimum_required(VERSION 3.16)
|
|
||||||
project(archery_netcore CXX)
|
|
||||||
|
|
||||||
set(CMAKE_SYSTEM_NAME Linux)
|
|
||||||
set(CMAKE_SYSTEM_PROCESSOR riscv64)
|
|
||||||
set(CMAKE_CXX_STANDARD 17)
|
|
||||||
set(CMAKE_POSITION_INDEPENDENT_CODE ON)
|
|
||||||
|
|
||||||
if(NOT DEFINED PY_INCLUDE_DIR)
|
|
||||||
message(FATAL_ERROR "PY_INCLUDE_DIR not set")
|
|
||||||
endif()
|
|
||||||
if(NOT DEFINED PY_LIB)
|
|
||||||
message(FATAL_ERROR "PY_LIB not set")
|
|
||||||
endif()
|
|
||||||
if(NOT DEFINED PY_EXT_SUFFIX)
|
|
||||||
message(FATAL_ERROR "PY_EXT_SUFFIX not set")
|
|
||||||
endif()
|
|
||||||
if(NOT DEFINED MAIXCDK_PATH)
|
|
||||||
message(FATAL_ERROR "MAIXCDK_PATH not set (need components/3rd_party/pybind11)")
|
|
||||||
endif()
|
|
||||||
|
|
||||||
add_library(archery_netcore MODULE
|
|
||||||
archery_netcore.cpp
|
|
||||||
native_logger.cpp
|
|
||||||
utils.cpp
|
|
||||||
decrypt_ota_file.cpp
|
|
||||||
msg_handler.cpp
|
|
||||||
tcp_ssl_password.cpp
|
|
||||||
)
|
|
||||||
|
|
||||||
target_include_directories(archery_netcore PRIVATE
|
|
||||||
"${PY_INCLUDE_DIR}"
|
|
||||||
"${MAIXCDK_PATH}/components/3rd_party/pybind11/pybind11/include"
|
|
||||||
"${MAIXCDK_PATH}/components/3rd_party/openssl/include"
|
|
||||||
"${CMAKE_CURRENT_SOURCE_DIR}/third_party" # 添加 nlohmann/json 路径
|
|
||||||
)
|
|
||||||
|
|
||||||
# 尽量减少 .so 体积并增加逆向成本
|
|
||||||
target_compile_options(archery_netcore PRIVATE
|
|
||||||
-Os
|
|
||||||
-ffunction-sections
|
|
||||||
-fdata-sections
|
|
||||||
-fvisibility=hidden
|
|
||||||
-fvisibility-inlines-hidden
|
|
||||||
)
|
|
||||||
target_link_options(archery_netcore PRIVATE
|
|
||||||
-Wl,--gc-sections
|
|
||||||
-Wl,-s
|
|
||||||
)
|
|
||||||
|
|
||||||
set_target_properties(archery_netcore PROPERTIES
|
|
||||||
PREFIX ""
|
|
||||||
SUFFIX "${PY_EXT_SUFFIX}"
|
|
||||||
)
|
|
||||||
|
|
||||||
# OpenSSL (for AES-256-GCM decrypt)
|
|
||||||
# 使用 MaixCDK 提供的 OpenSSL 库(在 so/maixcam 目录下)
|
|
||||||
set(OPENSSL_LIB_DIR "${MAIXCDK_PATH}/components/3rd_party/openssl/so/maixcam")
|
|
||||||
if(EXISTS "${OPENSSL_LIB_DIR}/libcrypto.so")
|
|
||||||
target_link_directories(archery_netcore PRIVATE "${OPENSSL_LIB_DIR}")
|
|
||||||
target_link_libraries(archery_netcore PRIVATE "${PY_LIB}" crypto ssl)
|
|
||||||
message(STATUS "Using OpenSSL from MaixCDK: ${OPENSSL_LIB_DIR}")
|
|
||||||
else()
|
|
||||||
# Fallback: 尝试 find_package 或系统库
|
|
||||||
find_package(OpenSSL QUIET)
|
|
||||||
if(OpenSSL_FOUND)
|
|
||||||
target_link_libraries(archery_netcore PRIVATE "${PY_LIB}" OpenSSL::Crypto OpenSSL::SSL)
|
|
||||||
else()
|
|
||||||
message(WARNING "OpenSSL not found in MaixCDK, trying system libraries (may fail)")
|
|
||||||
target_link_libraries(archery_netcore PRIVATE "${PY_LIB}" crypto ssl)
|
|
||||||
endif()
|
|
||||||
endif()
|
|
||||||
@@ -1,117 +0,0 @@
|
|||||||
#include <pybind11/pybind11.h>
|
|
||||||
#include <pybind11/stl.h> // 支持 std::vector, std::map 等
|
|
||||||
#include <nlohmann/json.hpp>
|
|
||||||
#include <cstring>
|
|
||||||
#include <cstdint>
|
|
||||||
#include <vector>
|
|
||||||
#include <string>
|
|
||||||
#include <fstream>
|
|
||||||
#include <array>
|
|
||||||
|
|
||||||
#include "msg_handler.hpp"
|
|
||||||
#include "native_logger.hpp"
|
|
||||||
#include "decrypt_ota_file.hpp"
|
|
||||||
#include "utils.hpp"
|
|
||||||
#include "tcp_ssl_password.hpp"
|
|
||||||
|
|
||||||
namespace py = pybind11;
|
|
||||||
using json = nlohmann::json;
|
|
||||||
|
|
||||||
namespace {
|
|
||||||
// 配置项
|
|
||||||
const std::string _cfg_server_ip = "www.shelingxingqiu.com";
|
|
||||||
const int _cfg_server_port = 50005;
|
|
||||||
const std::string _cfg_device_id_file = "/device_key";
|
|
||||||
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
||||||
// 定义获取配置的函数
|
|
||||||
py::dict get_config() {
|
|
||||||
py::dict config;
|
|
||||||
config["SERVER_IP"] = _cfg_server_ip;
|
|
||||||
config["SERVER_PORT"] = _cfg_server_port;
|
|
||||||
return config;
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
PYBIND11_MODULE(archery_netcore, m) {
|
|
||||||
m.doc() = "Archery net core (native, pybind11).";
|
|
||||||
|
|
||||||
// Optional: configure native logger from Python.
|
|
||||||
// Default log file: /maixapp/apps/t11/netcore.log
|
|
||||||
m.def("set_log_file", [](const std::string& path) { netcore::set_log_file(path); }, py::arg("path"));
|
|
||||||
m.def("set_log_level", [](int level) {
|
|
||||||
if (level < 0) level = 0;
|
|
||||||
if (level > 3) level = 3;
|
|
||||||
netcore::set_log_level(static_cast<netcore::LogLevel>(level));
|
|
||||||
}, py::arg("level"));
|
|
||||||
m.def("log_test", [](const std::string& msg) {
|
|
||||||
netcore::log_info(std::string("log_test: ") + msg);
|
|
||||||
}, py::arg("msg"));
|
|
||||||
|
|
||||||
m.def("make_packet", &netcore::make_packet,
|
|
||||||
"Pack TCP packet: header (len+type+checksum) + JSON body",
|
|
||||||
py::arg("msg_type"), py::arg("body_dict"));
|
|
||||||
|
|
||||||
m.def("parse_packet", &netcore::parse_packet,
|
|
||||||
"Parse TCP packet, return (msg_type, body_dict)");
|
|
||||||
|
|
||||||
m.def("get_config", &get_config, "Get system configuration");
|
|
||||||
|
|
||||||
m.def(
|
|
||||||
"calculate_tcp_ssl_password",
|
|
||||||
&netcore::calculate_tcp_ssl_password,
|
|
||||||
"Calculate TCP SSL password: hex(md5(hex(md5(device_id)) + iccid))",
|
|
||||||
py::arg("device_id"),
|
|
||||||
py::arg("iccid")
|
|
||||||
);
|
|
||||||
|
|
||||||
m.def(
|
|
||||||
"decrypt_ota_file",
|
|
||||||
[](const std::string& input_path, const std::string& output_zip_path) {
|
|
||||||
netcore::log_info(std::string("decrypt_ota_file in=") + input_path + " out=" + output_zip_path);
|
|
||||||
return netcore::decrypt_ota_file_impl(input_path, output_zip_path);
|
|
||||||
},
|
|
||||||
py::arg("input_path"),
|
|
||||||
py::arg("output_zip_path"),
|
|
||||||
"Decrypt OTA encrypted file (MAGIC|nonce|ciphertext|tag) to plaintext zip."
|
|
||||||
);
|
|
||||||
|
|
||||||
// Minimal demo: return actions for inner_cmd=41 (manual trigger + ack)
|
|
||||||
m.def("actions_for_inner_cmd", [](int inner_cmd) {
|
|
||||||
py::list actions;
|
|
||||||
|
|
||||||
if (inner_cmd == 41) {
|
|
||||||
// 1) set manual trigger flag
|
|
||||||
{
|
|
||||||
py::dict a;
|
|
||||||
a["type"] = "SET_FLAG";
|
|
||||||
py::dict args;
|
|
||||||
args["name"] = "manual_trigger_flag";
|
|
||||||
args["value"] = true;
|
|
||||||
a["args"] = args;
|
|
||||||
actions.append(a);
|
|
||||||
}
|
|
||||||
|
|
||||||
// 2) enqueue trigger_ack
|
|
||||||
{
|
|
||||||
py::dict a;
|
|
||||||
a["type"] = "ENQUEUE";
|
|
||||||
py::dict args;
|
|
||||||
args["msg_type"] = 2;
|
|
||||||
args["high"] = false;
|
|
||||||
py::dict body;
|
|
||||||
body["result"] = "trigger_ack";
|
|
||||||
args["body"] = body;
|
|
||||||
a["args"] = args;
|
|
||||||
actions.append(a);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return actions;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
@@ -1,155 +0,0 @@
|
|||||||
#include <cstring>
|
|
||||||
#include <cstdint>
|
|
||||||
#include <vector>
|
|
||||||
#include <string>
|
|
||||||
#include <fstream>
|
|
||||||
#include <array>
|
|
||||||
#include <algorithm>
|
|
||||||
#include <openssl/evp.h>
|
|
||||||
#include "native_logger.hpp"
|
|
||||||
|
|
||||||
namespace netcore{
|
|
||||||
|
|
||||||
// OTA AEAD format: MAGIC(7) | nonce(12) | ciphertext(N) | tag(16)
|
|
||||||
constexpr const char* kOtaMagic = "AROTAE1";
|
|
||||||
constexpr size_t kOtaMagicLen = 7;
|
|
||||||
constexpr size_t kGcmNonceLen = 12;
|
|
||||||
constexpr size_t kGcmTagLen = 16;
|
|
||||||
constexpr size_t kHeaderLen = kOtaMagicLen + kGcmNonceLen;
|
|
||||||
// 分块解密,避免整包读入导致 RAM 峰值约为「文件大小×2」(小内存设备易 OOM)
|
|
||||||
constexpr size_t kDecryptChunk = 65536;
|
|
||||||
|
|
||||||
static std::array<uint8_t, 32> ota_key_bytes() {
|
|
||||||
static const std::array<uint8_t, 32> a = {
|
|
||||||
0x92,0x99,0x4d,0x06,0x6f,0xb6,0xa6,0x3d,0x85,0x08,0xbe,0x73,0x5e,0x73,0x4d,0x8a,
|
|
||||||
0x53,0x88,0xe6,0x99,0xfc,0x10,0x29,0xb9,0x16,0x9b,0xe7,0x0c,0x65,0x21,0x1c,0xce
|
|
||||||
};
|
|
||||||
static const std::array<uint8_t, 32> b = {
|
|
||||||
0xcf,0x60,0xa2,0xc2,0x32,0x7a,0x61,0xb0,0x4c,0x8e,0x8a,0x62,0x31,0xc7,0x82,0xff,
|
|
||||||
0xec,0xac,0xa1,0x04,0x2a,0x4d,0xaa,0xf2,0xb0,0x5b,0x39,0x2b,0xf4,0xb3,0xad,0xad
|
|
||||||
};
|
|
||||||
std::array<uint8_t, 32> k{};
|
|
||||||
for (size_t i = 0; i < k.size(); i++) k[i] = static_cast<uint8_t>(a[i] ^ b[i]);
|
|
||||||
return k;
|
|
||||||
}
|
|
||||||
|
|
||||||
bool decrypt_ota_file_impl(const std::string& input_path, const std::string& output_zip_path) {
|
|
||||||
std::ifstream ifs(input_path, std::ios::binary);
|
|
||||||
if (!ifs) {
|
|
||||||
netcore::log_error(std::string("decrypt_ota_file: open in failed: ") + input_path);
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
ifs.seekg(0, std::ios::end);
|
|
||||||
const std::streampos szp = ifs.tellg();
|
|
||||||
if (szp <= 0) {
|
|
||||||
netcore::log_error("decrypt_ota_file: empty input");
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
const uint64_t file_size = static_cast<uint64_t>(szp);
|
|
||||||
const size_t min_len = kHeaderLen + kGcmTagLen + 1;
|
|
||||||
if (file_size < min_len) {
|
|
||||||
netcore::log_error("decrypt_ota_file: too short");
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
const uint64_t ciphertext_len = file_size - kHeaderLen - kGcmTagLen;
|
|
||||||
|
|
||||||
ifs.seekg(0, std::ios::beg);
|
|
||||||
std::array<uint8_t, kHeaderLen> header{};
|
|
||||||
ifs.read(reinterpret_cast<char*>(header.data()), static_cast<std::streamsize>(kHeaderLen));
|
|
||||||
if (ifs.gcount() != static_cast<std::streamsize>(kHeaderLen)) {
|
|
||||||
netcore::log_error("decrypt_ota_file: read header failed");
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
if (!std::equal(header.begin(), header.begin() + kOtaMagicLen,
|
|
||||||
reinterpret_cast<const uint8_t*>(kOtaMagic))) {
|
|
||||||
netcore::log_error("decrypt_ota_file: bad magic");
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
const uint8_t* nonce = header.data() + kOtaMagicLen;
|
|
||||||
|
|
||||||
std::ofstream ofs(output_zip_path, std::ios::binary | std::ios::trunc);
|
|
||||||
if (!ofs) {
|
|
||||||
netcore::log_error(std::string("decrypt_ota_file: open out failed: ") + output_zip_path);
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
EVP_CIPHER_CTX* ctx = EVP_CIPHER_CTX_new();
|
|
||||||
if (!ctx) {
|
|
||||||
netcore::log_error("decrypt_ota_file: EVP_CIPHER_CTX_new failed");
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
bool ok = false;
|
|
||||||
auto key = ota_key_bytes();
|
|
||||||
std::vector<uint8_t> chunk_in(kDecryptChunk);
|
|
||||||
std::vector<uint8_t> chunk_out(kDecryptChunk + EVP_MAX_BLOCK_LENGTH);
|
|
||||||
|
|
||||||
do {
|
|
||||||
if (1 != EVP_DecryptInit_ex(ctx, EVP_aes_256_gcm(), nullptr, nullptr, nullptr)) {
|
|
||||||
netcore::log_error("decrypt_ota_file: DecryptInit failed");
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
if (1 != EVP_CIPHER_CTX_ctrl(ctx, EVP_CTRL_GCM_SET_IVLEN, static_cast<int>(kGcmNonceLen), nullptr)) {
|
|
||||||
netcore::log_error("decrypt_ota_file: set ivlen failed");
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
if (1 != EVP_DecryptInit_ex(ctx, nullptr, nullptr, key.data(), nonce)) {
|
|
||||||
netcore::log_error("decrypt_ota_file: set key/iv failed");
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
|
|
||||||
uint64_t remaining = ciphertext_len;
|
|
||||||
while (remaining > 0) {
|
|
||||||
const size_t n = static_cast<size_t>(std::min<uint64_t>(remaining, kDecryptChunk));
|
|
||||||
ifs.read(reinterpret_cast<char*>(chunk_in.data()), static_cast<std::streamsize>(n));
|
|
||||||
if (ifs.gcount() != static_cast<std::streamsize>(n)) {
|
|
||||||
netcore::log_error("decrypt_ota_file: read ciphertext chunk failed");
|
|
||||||
goto cleanup_ctx;
|
|
||||||
}
|
|
||||||
int outl = 0;
|
|
||||||
if (1 != EVP_DecryptUpdate(ctx, chunk_out.data(), &outl,
|
|
||||||
chunk_in.data(), static_cast<int>(n))) {
|
|
||||||
netcore::log_error("decrypt_ota_file: update failed");
|
|
||||||
goto cleanup_ctx;
|
|
||||||
}
|
|
||||||
if (outl > 0) {
|
|
||||||
ofs.write(reinterpret_cast<const char*>(chunk_out.data()), outl);
|
|
||||||
if (!ofs) {
|
|
||||||
netcore::log_error("decrypt_ota_file: write plaintext failed");
|
|
||||||
goto cleanup_ctx;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
remaining -= n;
|
|
||||||
}
|
|
||||||
|
|
||||||
std::array<uint8_t, kGcmTagLen> tag{};
|
|
||||||
ifs.read(reinterpret_cast<char*>(tag.data()), static_cast<std::streamsize>(kGcmTagLen));
|
|
||||||
if (ifs.gcount() != static_cast<std::streamsize>(kGcmTagLen)) {
|
|
||||||
netcore::log_error("decrypt_ota_file: read tag failed");
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
if (1 != EVP_CIPHER_CTX_ctrl(ctx, EVP_CTRL_GCM_SET_TAG, static_cast<int>(kGcmTagLen), tag.data())) {
|
|
||||||
netcore::log_error("decrypt_ota_file: set tag failed");
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
|
|
||||||
int outl2 = 0;
|
|
||||||
if (1 != EVP_DecryptFinal_ex(ctx, chunk_out.data(), &outl2)) {
|
|
||||||
netcore::log_error("decrypt_ota_file: final failed (auth tag mismatch?)");
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
if (outl2 > 0) {
|
|
||||||
ofs.write(reinterpret_cast<const char*>(chunk_out.data()), outl2);
|
|
||||||
if (!ofs) {
|
|
||||||
netcore::log_error("decrypt_ota_file: write final failed");
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
ok = true;
|
|
||||||
} while (false);
|
|
||||||
|
|
||||||
cleanup_ctx:
|
|
||||||
EVP_CIPHER_CTX_free(ctx);
|
|
||||||
return ok;
|
|
||||||
}
|
|
||||||
} // namespace netcore
|
|
||||||
@@ -1,7 +0,0 @@
|
|||||||
#pragma once
|
|
||||||
|
|
||||||
#include <string>
|
|
||||||
|
|
||||||
namespace netcore{
|
|
||||||
bool decrypt_ota_file_impl(const std::string& input_path, const std::string& output_zip_path);
|
|
||||||
}
|
|
||||||
@@ -1,113 +0,0 @@
|
|||||||
#include <nlohmann/json.hpp>
|
|
||||||
#include <string>
|
|
||||||
#include <cstring>
|
|
||||||
#include <cstdint>
|
|
||||||
#include <vector>
|
|
||||||
#include "native_logger.hpp"
|
|
||||||
#include "msg_handler.hpp"
|
|
||||||
#include "utils.hpp"
|
|
||||||
|
|
||||||
namespace py = pybind11;
|
|
||||||
using json = nlohmann::json;
|
|
||||||
|
|
||||||
namespace netcore {
|
|
||||||
// 打包 TCP 数据包
|
|
||||||
py::bytes make_packet(int msg_type, py::dict body_dict) {
|
|
||||||
netcore::log_debug(std::string("make_packet msg_type=") + std::to_string(msg_type));
|
|
||||||
// 1) 将 py::dict 转为 JSON 字符串
|
|
||||||
json j = netcore::py_dict_to_json(body_dict);
|
|
||||||
std::string body_str = j.dump();
|
|
||||||
|
|
||||||
// 2) 计算 body_len 和 checksum
|
|
||||||
uint32_t body_len = body_str.size();
|
|
||||||
uint32_t checksum = body_len + msg_type;
|
|
||||||
|
|
||||||
// 3) 打包头部(大端序)
|
|
||||||
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);
|
|
||||||
|
|
||||||
// 4) 追加 body
|
|
||||||
packet.insert(packet.end(), body_str.begin(), body_str.end());
|
|
||||||
|
|
||||||
netcore::log_debug(std::string("make_packet 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
|
|
||||||
py::buffer_info buf = py::buffer(data).request();
|
|
||||||
if (buf.size < 12) {
|
|
||||||
netcore::log_error(std::string("parse_packet too_short len=") + std::to_string(buf.size));
|
|
||||||
return py::make_tuple(py::none(), py::none());
|
|
||||||
}
|
|
||||||
|
|
||||||
const uint8_t* ptr = static_cast<const uint8_t*>(buf.ptr);
|
|
||||||
|
|
||||||
// 2) 解析头部(大端序)
|
|
||||||
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 checksum = (ptr[8] << 24) | (ptr[9] << 16) | (ptr[10] << 8) | ptr[11];
|
|
||||||
|
|
||||||
// 3) 校验 checksum(可选,你现有代码不强制校验)
|
|
||||||
// if (checksum != (body_len + msg_type)) {
|
|
||||||
// return py::make_tuple(py::none(), py::none());
|
|
||||||
// }
|
|
||||||
|
|
||||||
// 4) 检查长度
|
|
||||||
uint32_t expected_len = 12 + body_len;
|
|
||||||
if (buf.size < expected_len) {
|
|
||||||
// 半包
|
|
||||||
netcore::log_warn(std::string("parse_packet incomplete got=") + std::to_string(buf.size) +
|
|
||||||
" expected=" + std::to_string(expected_len));
|
|
||||||
return py::make_tuple(py::none(), py::none());
|
|
||||||
}
|
|
||||||
|
|
||||||
// 5) 防御性检查:如果 data 比预期长,说明可能有粘包
|
|
||||||
// (只解析第一个包,忽略多余数据)
|
|
||||||
if (buf.size > expected_len) {
|
|
||||||
netcore::log_warn(std::string("parse_packet concat got=") + std::to_string(buf.size) +
|
|
||||||
" expected=" + std::to_string(expected_len) +
|
|
||||||
" body_len=" + std::to_string(body_len) +
|
|
||||||
" msg_type=" + std::to_string(msg_type));
|
|
||||||
}
|
|
||||||
|
|
||||||
// 6) 提取 body 并解析 JSON
|
|
||||||
std::string body_str(reinterpret_cast<const char*>(ptr + 12), body_len);
|
|
||||||
|
|
||||||
try {
|
|
||||||
json j = json::parse(body_str);
|
|
||||||
py::dict body_dict = netcore::json_to_py_dict(j);
|
|
||||||
return py::make_tuple(py::int_(msg_type), body_dict);
|
|
||||||
} catch (const json::parse_error& e) {
|
|
||||||
// JSON 解析失败,返回 raw(兼容你现有的逻辑)
|
|
||||||
netcore::log_error(std::string("parse_packet json_parse_error: ") + e.what());
|
|
||||||
py::dict raw_dict;
|
|
||||||
raw_dict["raw"] = body_str;
|
|
||||||
return py::make_tuple(py::int_(msg_type), raw_dict);
|
|
||||||
} catch (const std::exception& e) {
|
|
||||||
netcore::log_error(std::string("parse_packet json_parse_error: ") + e.what());
|
|
||||||
py::dict raw_dict;
|
|
||||||
raw_dict["raw"] = body_str;
|
|
||||||
return py::make_tuple(py::int_(msg_type), raw_dict);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,14 +0,0 @@
|
|||||||
#pragma once
|
|
||||||
|
|
||||||
#include <pybind11/pybind11.h>
|
|
||||||
#include <pybind11/stl.h> // 支持 std::vector, std::map 等
|
|
||||||
|
|
||||||
namespace py = pybind11;
|
|
||||||
|
|
||||||
namespace netcore {
|
|
||||||
|
|
||||||
// 打包 TCP 数据包
|
|
||||||
py::bytes make_packet(int msg_type, py::dict body_dict);
|
|
||||||
// 解包 TCP 数据包
|
|
||||||
py::tuple parse_packet(py::bytes data);
|
|
||||||
}
|
|
||||||
@@ -1,100 +0,0 @@
|
|||||||
#include "native_logger.hpp"
|
|
||||||
|
|
||||||
#include <cerrno>
|
|
||||||
#include <cstring>
|
|
||||||
#include <mutex>
|
|
||||||
#include <string>
|
|
||||||
|
|
||||||
#include <fcntl.h>
|
|
||||||
#include <sys/stat.h>
|
|
||||||
#include <sys/types.h>
|
|
||||||
#include <time.h>
|
|
||||||
#include <unistd.h>
|
|
||||||
|
|
||||||
namespace netcore {
|
|
||||||
|
|
||||||
static std::mutex g_mu;
|
|
||||||
static int g_fd = -1;
|
|
||||||
static std::string g_path = "netcore.log";
|
|
||||||
static LogLevel g_level = LogLevel::kDebug; //LogLevel::kInfo;
|
|
||||||
|
|
||||||
static const char* level_name(LogLevel lvl) {
|
|
||||||
switch (lvl) {
|
|
||||||
case LogLevel::kError: return "E";
|
|
||||||
case LogLevel::kWarn: return "W";
|
|
||||||
case LogLevel::kInfo: return "I";
|
|
||||||
case LogLevel::kDebug: return "D";
|
|
||||||
default: return "?";
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
static void ensure_open_locked() {
|
|
||||||
if (g_path.empty()) return;
|
|
||||||
if (g_fd >= 0) return;
|
|
||||||
g_fd = ::open(g_path.c_str(), O_CREAT | O_WRONLY | O_APPEND, 0644);
|
|
||||||
}
|
|
||||||
|
|
||||||
void set_log_file(const std::string& path) {
|
|
||||||
std::lock_guard<std::mutex> lk(g_mu);
|
|
||||||
g_path = path;
|
|
||||||
if (g_fd >= 0) {
|
|
||||||
::close(g_fd);
|
|
||||||
g_fd = -1;
|
|
||||||
}
|
|
||||||
ensure_open_locked();
|
|
||||||
}
|
|
||||||
|
|
||||||
void set_log_level(LogLevel level) {
|
|
||||||
std::lock_guard<std::mutex> lk(g_mu);
|
|
||||||
g_level = level;
|
|
||||||
}
|
|
||||||
|
|
||||||
void log(LogLevel level, const std::string& msg) {
|
|
||||||
std::lock_guard<std::mutex> lk(g_mu);
|
|
||||||
if (static_cast<int>(level) > static_cast<int>(g_level)) return;
|
|
||||||
if (g_path.empty()) return;
|
|
||||||
|
|
||||||
ensure_open_locked();
|
|
||||||
if (g_fd < 0) {
|
|
||||||
// Last resort: stderr (avoid any Python APIs)
|
|
||||||
::write(STDERR_FILENO, msg.c_str(), msg.size());
|
|
||||||
::write(STDERR_FILENO, "\n", 1);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Timestamp: epoch milliseconds (simple and cheap)
|
|
||||||
struct timespec ts;
|
|
||||||
clock_gettime(CLOCK_REALTIME, &ts);
|
|
||||||
// long long ms = (long long)ts.tv_sec * 1000LL + ts.tv_nsec / 1000000LL;
|
|
||||||
// 1. 将秒数转换为本地时间结构体 struct tm
|
|
||||||
struct tm *tm_info = localtime(&ts.tv_sec);
|
|
||||||
|
|
||||||
// 2. 准备一个缓冲区来存储时间字符串
|
|
||||||
char buffer[30];
|
|
||||||
|
|
||||||
// 3. 格式化秒的部分
|
|
||||||
// 格式: 年-月-日 时:分:秒
|
|
||||||
strftime(buffer, sizeof(buffer), "%Y-%m-%d %H:%M:%S", tm_info);
|
|
||||||
|
|
||||||
// 4. 计算毫秒部分并追加到字符串中
|
|
||||||
// ts.tv_nsec 是纳秒,除以 1,000,000 得到毫秒
|
|
||||||
char ms_buffer[8];
|
|
||||||
snprintf(ms_buffer, sizeof(ms_buffer), ".%03ld", ts.tv_nsec / 1000000);
|
|
||||||
|
|
||||||
// Build one line to keep writes atomic-ish
|
|
||||||
char head[256];
|
|
||||||
int n = ::snprintf(head, sizeof(head), "[%s%s] [%s] ", buffer, ms_buffer, level_name(level));
|
|
||||||
if (n < 0) n = 0;
|
|
||||||
|
|
||||||
::write(g_fd, head, (size_t)n);
|
|
||||||
::write(g_fd, msg.c_str(), msg.size());
|
|
||||||
::write(g_fd, "\n", 1);
|
|
||||||
}
|
|
||||||
|
|
||||||
void log_debug(const std::string& msg) { log(LogLevel::kDebug, msg); }
|
|
||||||
void log_info (const std::string& msg) { log(LogLevel::kInfo, msg); }
|
|
||||||
void log_warn (const std::string& msg) { log(LogLevel::kWarn, msg); }
|
|
||||||
void log_error(const std::string& msg) { log(LogLevel::kError, msg); }
|
|
||||||
|
|
||||||
} // namespace netcore
|
|
||||||
|
|
||||||
@@ -1,28 +0,0 @@
|
|||||||
#pragma once
|
|
||||||
|
|
||||||
#include <string>
|
|
||||||
|
|
||||||
namespace netcore {
|
|
||||||
|
|
||||||
enum class LogLevel : int {
|
|
||||||
kError = 0,
|
|
||||||
kWarn = 1,
|
|
||||||
kInfo = 2,
|
|
||||||
kDebug = 3,
|
|
||||||
};
|
|
||||||
|
|
||||||
// Set log file path. If empty, logging is disabled.
|
|
||||||
void set_log_file(const std::string& path);
|
|
||||||
|
|
||||||
// Set minimum log level to write (default: kInfo).
|
|
||||||
void set_log_level(LogLevel level);
|
|
||||||
|
|
||||||
// Log helpers (thread-safe, never calls into Python).
|
|
||||||
void log(LogLevel level, const std::string& msg);
|
|
||||||
void log_debug(const std::string& msg);
|
|
||||||
void log_info(const std::string& msg);
|
|
||||||
void log_warn(const std::string& msg);
|
|
||||||
void log_error(const std::string& msg);
|
|
||||||
|
|
||||||
} // namespace netcore
|
|
||||||
|
|
||||||
@@ -1,33 +0,0 @@
|
|||||||
#include "tcp_ssl_password.hpp"
|
|
||||||
|
|
||||||
#include <openssl/md5.h>
|
|
||||||
#include <sstream>
|
|
||||||
#include <iomanip>
|
|
||||||
|
|
||||||
namespace netcore {
|
|
||||||
|
|
||||||
static std::string md5_hex(const std::string& input) {
|
|
||||||
MD5_CTX ctx;
|
|
||||||
MD5_Init(&ctx);
|
|
||||||
MD5_Update(&ctx, input.data(), input.size());
|
|
||||||
|
|
||||||
unsigned char digest[MD5_DIGEST_LENGTH];
|
|
||||||
MD5_Final(digest, &ctx);
|
|
||||||
|
|
||||||
std::ostringstream oss;
|
|
||||||
oss << std::hex << std::setfill('0');
|
|
||||||
for (int i = 0; i < MD5_DIGEST_LENGTH; ++i) {
|
|
||||||
oss << std::setw(2) << static_cast<unsigned int>(digest[i]);
|
|
||||||
}
|
|
||||||
return oss.str();
|
|
||||||
}
|
|
||||||
|
|
||||||
std::string calculate_tcp_ssl_password(const std::string& device_id, const std::string& iccid) {
|
|
||||||
std::string md5_device_hex = md5_hex(device_id);
|
|
||||||
if (!iccid.empty()) {
|
|
||||||
md5_device_hex += iccid;
|
|
||||||
}
|
|
||||||
return md5_hex(md5_device_hex);
|
|
||||||
}
|
|
||||||
|
|
||||||
} // namespace netcore
|
|
||||||
@@ -1,7 +0,0 @@
|
|||||||
#pragma once
|
|
||||||
|
|
||||||
#include <string>
|
|
||||||
|
|
||||||
namespace netcore {
|
|
||||||
std::string calculate_tcp_ssl_password(const std::string& device_id, const std::string& iccid);
|
|
||||||
}
|
|
||||||
-24765
File diff suppressed because it is too large
Load Diff
@@ -1,95 +0,0 @@
|
|||||||
#include <fstream>
|
|
||||||
#include <cstring>
|
|
||||||
#include <cstdint>
|
|
||||||
#include <string>
|
|
||||||
#include <fstream>
|
|
||||||
#include "utils.hpp"
|
|
||||||
|
|
||||||
namespace netcore {
|
|
||||||
// 辅助函数:将 py::dict 转为 nlohmann::json
|
|
||||||
json py_dict_to_json(py::dict d) {
|
|
||||||
json j;
|
|
||||||
for (auto item : d) {
|
|
||||||
std::string key = py::str(item.first);
|
|
||||||
py::object val = py::reinterpret_borrow<py::object>(item.second);
|
|
||||||
|
|
||||||
if (py::isinstance<py::dict>(val)) {
|
|
||||||
j[key] = py_dict_to_json(py::cast<py::dict>(val));
|
|
||||||
} else if (py::isinstance<py::list>(val)) {
|
|
||||||
py::list py_list = py::cast<py::list>(val);
|
|
||||||
json arr = json::array();
|
|
||||||
for (auto elem : py_list) {
|
|
||||||
py::object elem_obj = py::reinterpret_borrow<py::object>(elem);
|
|
||||||
if (py::isinstance<py::dict>(elem_obj)) {
|
|
||||||
arr.push_back(py_dict_to_json(py::cast<py::dict>(elem_obj)));
|
|
||||||
} else if (py::isinstance<py::int_>(elem_obj)) {
|
|
||||||
arr.push_back(py::cast<int64_t>(elem_obj));
|
|
||||||
} else if (py::isinstance<py::float_>(elem_obj)) {
|
|
||||||
arr.push_back(py::cast<double>(elem_obj));
|
|
||||||
} else {
|
|
||||||
arr.push_back(py::str(elem_obj));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
j[key] = arr;
|
|
||||||
} else if (py::isinstance<py::int_>(val)) {
|
|
||||||
j[key] = py::cast<int64_t>(val);
|
|
||||||
} else if (py::isinstance<py::float_>(val)) {
|
|
||||||
j[key] = py::cast<double>(val);
|
|
||||||
} else if (py::isinstance<py::bool_>(val)) {
|
|
||||||
j[key] = py::cast<bool>(val);
|
|
||||||
} else if (val.is_none()) {
|
|
||||||
j[key] = nullptr;
|
|
||||||
} else {
|
|
||||||
j[key] = py::str(val);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return j;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 辅助函数:将 nlohmann::json 转为 py::dict
|
|
||||||
py::dict json_to_py_dict(const json& j) {
|
|
||||||
py::dict d;
|
|
||||||
if (j.is_object()) {
|
|
||||||
for (auto& item : j.items()) {
|
|
||||||
std::string key = item.key();
|
|
||||||
json val = item.value();
|
|
||||||
|
|
||||||
if (val.is_object()) {
|
|
||||||
d[py::str(key)] = json_to_py_dict(val);
|
|
||||||
} else if (val.is_array()) {
|
|
||||||
py::list py_list;
|
|
||||||
for (auto& elem : val) {
|
|
||||||
if (elem.is_object()) {
|
|
||||||
py_list.append(json_to_py_dict(elem));
|
|
||||||
} else if (elem.is_number_integer()) {
|
|
||||||
py_list.append(py::int_(elem.get<int64_t>()));
|
|
||||||
} else if (elem.is_number_float()) {
|
|
||||||
py_list.append(py::float_(elem.get<double>()));
|
|
||||||
} else if (elem.is_boolean()) {
|
|
||||||
py_list.append(py::bool_(elem.get<bool>()));
|
|
||||||
} else if (elem.is_null()) {
|
|
||||||
py_list.append(py::none());
|
|
||||||
} else {
|
|
||||||
py_list.append(py::str(elem.get<std::string>()));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
d[py::str(key)] = py_list;
|
|
||||||
} else if (val.is_number_integer()) {
|
|
||||||
d[py::str(key)] = py::int_(val.get<int64_t>());
|
|
||||||
} else if (val.is_number_float()) {
|
|
||||||
d[py::str(key)] = py::float_(val.get<double>());
|
|
||||||
} else if (val.is_boolean()) {
|
|
||||||
d[py::str(key)] = py::bool_(val.get<bool>());
|
|
||||||
} else if (val.is_null()) {
|
|
||||||
d[py::str(key)] = py::none();
|
|
||||||
} else {
|
|
||||||
d[py::str(key)] = py::str(val.get<std::string>());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return d;
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
}
|
|
||||||
@@ -1,15 +0,0 @@
|
|||||||
#pragma once
|
|
||||||
|
|
||||||
#include <pybind11/pybind11.h>
|
|
||||||
#include <pybind11/stl.h> // 支持 std::vector, std::map 等
|
|
||||||
#include <nlohmann/json.hpp>
|
|
||||||
#include <string>
|
|
||||||
|
|
||||||
namespace py = pybind11;
|
|
||||||
using json = nlohmann::json;
|
|
||||||
|
|
||||||
namespace netcore {
|
|
||||||
|
|
||||||
json py_dict_to_json(py::dict d);
|
|
||||||
py::dict json_to_py_dict(const json& j);
|
|
||||||
}
|
|
||||||
-61
@@ -1,61 +0,0 @@
|
|||||||
#!/usr/bin/env python3
|
|
||||||
# -*- coding: utf-8 -*-
|
|
||||||
"""
|
|
||||||
激光模块开关测试脚本
|
|
||||||
平台:MaixPy (Sipeed MAIX)
|
|
||||||
功能:每2秒循环开启/关闭激光,验证硬件是否正常响应
|
|
||||||
作者:ZZH
|
|
||||||
"""
|
|
||||||
|
|
||||||
from maix import uart, pinmap, time
|
|
||||||
|
|
||||||
# === 配置 ===
|
|
||||||
UART_PORT = "/dev/ttyS1" # 激光模块连接的串口(通常是 UART1)
|
|
||||||
BAUDRATE = 9600 # 波特率(根据你的模块调整)
|
|
||||||
|
|
||||||
# 引脚映射(根据你硬件连接修改)
|
|
||||||
pinmap.set_pin_function("A18", "UART1_RX") # RX
|
|
||||||
pinmap.set_pin_function("A19", "UART1_TX") # TX
|
|
||||||
|
|
||||||
# 激光控制指令(根据你的模块协议)
|
|
||||||
MODULE_ADDR = 0x00
|
|
||||||
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])
|
|
||||||
|
|
||||||
# === 初始化串口 ===
|
|
||||||
print("🔧 正在初始化激光串口...")
|
|
||||||
laser_uart = uart.UART(UART_PORT, BAUDRATE)
|
|
||||||
|
|
||||||
# === 辅助函数 ===
|
|
||||||
def send_laser_cmd(cmd, name):
|
|
||||||
"""发送激光指令并尝试读取回包"""
|
|
||||||
print(f"➡️ 发送指令: {name}")
|
|
||||||
laser_uart.write(cmd)
|
|
||||||
time.sleep_ms(50) # 等待模块处理
|
|
||||||
|
|
||||||
# 尝试读取回包(非必须,部分模块无返回)
|
|
||||||
resp = laser_uart.read(20)
|
|
||||||
if resp:
|
|
||||||
print(f"✅ 收到回包 ({len(resp)}字节): {resp.hex()}")
|
|
||||||
else:
|
|
||||||
print("🔇 无回包(正常或模块不支持)")
|
|
||||||
|
|
||||||
# === 主测试循环 ===
|
|
||||||
print("\n🚀 开始激光开关测试(按 Ctrl+C 停止)")
|
|
||||||
print("周期:开1秒 → 关1秒\n")
|
|
||||||
|
|
||||||
try:
|
|
||||||
while True:
|
|
||||||
# 开启激光
|
|
||||||
send_laser_cmd(LASER_ON_CMD, "LASER ON")
|
|
||||||
time.sleep(1.0) # 持续开启 1 秒
|
|
||||||
|
|
||||||
# 关闭激光
|
|
||||||
send_laser_cmd(LASER_OFF_CMD, "LASER OFF")
|
|
||||||
time.sleep(1.0) # 关闭 1 秒
|
|
||||||
|
|
||||||
except KeyboardInterrupt:
|
|
||||||
print("\n🛑 测试被用户中断")
|
|
||||||
# 最终确保激光关闭
|
|
||||||
laser_uart.write(LASER_OFF_CMD)
|
|
||||||
print("✅ 已发送最终关闭指令")
|
|
||||||
@@ -1,184 +0,0 @@
|
|||||||
1. 系统目标
|
|
||||||
# 检测靶纸四角的等腰直角三角形标记(每个角一个)
|
|
||||||
# 计算激光落点在靶面上的二维偏移(厘米)
|
|
||||||
# 通过PnP算法估算靶面到相机的距离(米)
|
|
||||||
|
|
||||||
2. 核心算法流程
|
|
||||||
2.1 三角形检测 (detect_triangle_markers)
|
|
||||||
采用多策略级联保证鲁棒性:
|
|
||||||
图像输入 → 多阈值策略 → 候选三角形过滤 → 四点匹配
|
|
||||||
检测策略(按优先级):
|
|
||||||
|
|
||||||
1.全局Otsu二值化(最快,~10ms)
|
|
||||||
2.自适应阈值(多种block size,光照不均时)
|
|
||||||
3.ROI局部阈值(候选不足3个时,分象限独立处理)
|
|
||||||
4.Black-Hat形态学增强(仍不足时,突出暗色标记)
|
|
||||||
|
|
||||||
三角形几何验证:
|
|
||||||
# 必须是直角三角形(检查勾股定理,容差20%)
|
|
||||||
# 两直角边长度差<20%
|
|
||||||
# 内部像素足够暗(灰度≤130,暗像素比例≥30%)
|
|
||||||
# 与周围背景对比度≥15灰度级
|
|
||||||
|
|
||||||
四点匹配算法:
|
|
||||||
# 从候选三角形中枚举所有4点组合
|
|
||||||
# 计算四边形评分:(对角比-1)*3 + (水平比-1) + (垂直比-1) + (边长偏差)*2
|
|
||||||
# 选择评分最低的组合作为四角标记
|
|
||||||
|
|
||||||
2.2 单应性落点计算 (homography_calibration)
|
|
||||||
建立图像坐标系 → 靶面坐标系(二维平面)的透视变换
|
|
||||||
|
|
||||||
将激光点像素坐标映射到靶面坐标(厘米)
|
|
||||||
|
|
||||||
使用RANSAC提高鲁棒性(阈值1像素)
|
|
||||||
|
|
||||||
2.3 PnP距离估计 (pnp_distance_meters)
|
|
||||||
已知四个标记点的三维坐标(x,y,z,单位cm)
|
|
||||||
|
|
||||||
通过solvePnP求解相机外参(旋转+平移)
|
|
||||||
|
|
||||||
距离 = ‖平移向量‖ / 100(转换为米)
|
|
||||||
|
|
||||||
3. 关键优化策略
|
|
||||||
3.1 多路径投票
|
|
||||||
同一图像区域被不同二值化方法检测到时,path_votes++
|
|
||||||
|
|
||||||
选择投票数高的候选,提高检测可信度
|
|
||||||
|
|
||||||
3.2 早退机制
|
|
||||||
候选≥3个 且 覆盖3个以上象限 → 停止更多阈值尝试
|
|
||||||
|
|
||||||
大幅降低嵌入式设备计算开销
|
|
||||||
|
|
||||||
3.3 3点补全机制
|
|
||||||
当只检测到3个角时,通过仿射变换估算第4个角位置
|
|
||||||
|
|
||||||
公式:P_missing = M_inv @ [x_target, y_target, 1]
|
|
||||||
|
|
||||||
3.4 图像缩放
|
|
||||||
默认缩放到0.5倍进行检测(由config控制)
|
|
||||||
|
|
||||||
坐标还原时乘以inv_scale,保持与标定矩阵一致
|
|
||||||
|
|
||||||
4. 数据流示例
|
|
||||||
python
|
|
||||||
输入:
|
|
||||||
- img_rgb: H×W×3 图像
|
|
||||||
- laser_xy: (x_px, y_px) 激光点像素坐标
|
|
||||||
- marker_positions: {0:[0,0,0], 1:[0,30,0], 2:[30,30,0], 3:[30,0,0]} # 4角3D坐标(cm)
|
|
||||||
|
|
||||||
输出:
|
|
||||||
{
|
|
||||||
"ok": True,
|
|
||||||
"dx_cm": 2.5, # 靶面X偏移(cm,向右为正)
|
|
||||||
"dy_cm": -3.2, # 靶面Y偏移(cm,向上为正)
|
|
||||||
"distance_m": 5.43, # 相机到靶面距离(米)
|
|
||||||
"offset_method": "triangle_homography",
|
|
||||||
"distance_method": "pnp_triangle"
|
|
||||||
}
|
|
||||||
5. 鲁棒性设计
|
|
||||||
5.1 参数自适应
|
|
||||||
从config.py动态读取所有阈值(可在线调整)
|
|
||||||
|
|
||||||
三角形边长范围、灰度阈值、对比度要求等均可配置
|
|
||||||
|
|
||||||
5.2 异常处理
|
|
||||||
角点退化检测(距离<3像素判定为重复)
|
|
||||||
|
|
||||||
NaN/Inf校验(单应性矩阵、偏移量、距离)
|
|
||||||
|
|
||||||
距离合理性检查(0.3~20米)
|
|
||||||
|
|
||||||
5.3 降级策略
|
|
||||||
PnP失败 → 只输出偏移,距离置None
|
|
||||||
|
|
||||||
4角检测失败 → 尝试3角补全
|
|
||||||
|
|
||||||
快速路径失败 → CLAHE增强兜底(可选)
|
|
||||||
|
|
||||||
6. 性能特点
|
|
||||||
CPU友好:默认Otsu单次处理,多数场景10-30ms完成检测
|
|
||||||
|
|
||||||
内存可控:最大候选数截断(默认10个),避免组合爆炸
|
|
||||||
|
|
||||||
嵌入式适配:支持图像缩放、早退机制降低计算量
|
|
||||||
|
|
||||||
7. 局限性
|
|
||||||
依赖四个等腰直角三角形(需靶纸特殊设计)
|
|
||||||
|
|
||||||
要求三角形内部足够暗、与背景有对比度
|
|
||||||
|
|
||||||
单应性假设靶面为平面(实际靶纸可能有轻微起伏)
|
|
||||||
|
|
||||||
这套算法在射击训练系统中作为主要定位手段。
|
|
||||||
|
|
||||||
8. 为了加速单应性的计算,引入了yolo模型,一共做了两个模型,一个为靶纸和黑色三角形一体的识别模型,用于做原照片上快速找到靶纸区域。另一个模型是黑色三角形的模型,用于做靶纸区域再找黑色三角形。但是经过对比发现,引入黑色三角形模型反而更慢。入下面的流程A和流程B:
|
|
||||||
yolo靶纸+传统(流程B) yolo靶纸+yolo黑色三角形(流程A)
|
|
||||||
平均值 646.08 916.4457143
|
|
||||||
标准差 94.61300968 57.40401849
|
|
||||||
|
|
||||||
公共前置(两条路都一样)
|
|
||||||
是否用靶环模型裁 Stage1
|
|
||||||
|
|
||||||
TRIANGLE_YOLO_ROI_ENABLE=True 时:跑 靶环 YOLO,得到全图上的 roi_xyxy,后面的三角形都在 img_work = 全图[roi] 上做(必要时再缩成 img_det 给整图传统分支用)。
|
|
||||||
False 时:roi_xyxy=None,三角形在 整幅相机图 上当 img_work。
|
|
||||||
之后都进入 try_triangle_scoring(img_cv, …, roi_xyxy=…, black_yolo_boxes_work=…)
|
|
||||||
|
|
||||||
在里面先做灰度、v_suppress、锐化、det_scale 缩略图等 prep(与是否黑三角模型无关)。
|
|
||||||
差别从 black_yolo_boxes_work 有没有有效子框列表 开始。
|
|
||||||
|
|
||||||
流程 A:用黑色三角形模型(Stage2 黑三角 YOLO)
|
|
||||||
配置要点:TRIANGLE_BLACK_YOLO_ENABLE=True,且 TRIANGLE_BLACK_TRIANGLE_LOCATE_MODE="yolo",并且 已有 Stage1 裁切(roi_xyxy 不能为 None,否则根本不会跑黑三角 YOLO)。
|
|
||||||
|
|
||||||
步骤概要:
|
|
||||||
|
|
||||||
try_black_triangle_boxes_work
|
|
||||||
|
|
||||||
输入:全图 RGB + Stage1 的 ring_roi_xyxy。
|
|
||||||
在 Stage1 裁切图(与训练一致的 slab)上跑 黑三角 YOLO,得到若干个 子框(black_boxes_work,坐标在 裁切图/work 系)。
|
|
||||||
try_triangle_scoring 内
|
|
||||||
|
|
||||||
若 black_yolo_boxes_work 非空:
|
|
||||||
按配置在 Stage1 全分辨率灰度(或缩略灰度,视 det_scale / TRIANGLE_BLACK_YOLO_PATCH_GRAY_SOURCE)上,对每个子框裁 patch,跑 _extract_triangle_from_yolo_patch(子框内:Otsu → 失败再单次 Adaptive + 轮廓 + 形状/颜色)。
|
|
||||||
median_leg 过滤,再 四点分配 ID。
|
|
||||||
若 ≥3 个(通常 4 个)有效:认为 Stage2 成功,跳过 整幅 Stage1 上的 detect_triangle_markers。
|
|
||||||
若 不足 3 个 且未关 fallback:在 缩略后的整幅 work 灰度上再走 detect_triangle_markers(整图 Otsu + 整图 Adaptive×block_sizes + 各类 fallback),与「不用黑三角模型时的传统主路径」同类。
|
|
||||||
后续
|
|
||||||
|
|
||||||
角点从 det 坐标 ×inv_scale 回到 work,再 +roi 原点 回到全图;单应性、补第 4 点、PnP 等与另一条路相同。
|
|
||||||
耗时上多出来的部分:黑三角 YOLO 推理 + 每个子框一遍传统小流水线(成功时通常 不再付整图 detect_triangle_markers)。
|
|
||||||
|
|
||||||
流程 B:不用黑色三角形模型(纯传统定位三角)
|
|
||||||
典型配置(任一即可达到「不用黑三角模型」的效果):
|
|
||||||
|
|
||||||
TRIANGLE_BLACK_YOLO_ENABLE=False,或
|
|
||||||
TRIANGLE_BLACK_TRIANGLE_LOCATE_MODE="traditional"(即使模型开关开着也不跑黑三角 YOLO),或
|
|
||||||
没有 Stage1 ROI(roi_xyxy is None)时,当前逻辑下 也不会跑 Stage2 黑三角 YOLO。
|
|
||||||
此时 black_yolo_boxes_work=None(或不等价于「有子框」)。
|
|
||||||
|
|
||||||
步骤概要:
|
|
||||||
|
|
||||||
try_triangle_scoring 内
|
|
||||||
不跑 子框 _extract_triangle_from_yolo_patch。
|
|
||||||
直接在 img_det(缩略后的 work) 上调用 detect_triangle_markers:
|
|
||||||
全局 Otsu(若 TRIANGLE_SKIP_GLOBAL_OTSU_EXTRACT_ON_YOLO_ROI 在有 ROI 时可能 不算 Otsu 轮廓,但仍会生成 Otsu 图供后续用);
|
|
||||||
可选 象限 ROI(TRIANGLE_ROI_ENABLED);
|
|
||||||
整图 Adaptive(TRIANGLE_ADAPTIVE_BLOCK_SIZES,例如 (11,));
|
|
||||||
不足再走 放宽 approxPolyDP、BlackHat 等。
|
|
||||||
后面同样是过滤、四点组合/象限分配、单应性、PnP 等。
|
|
||||||
特点:没有黑三角 NPU 时间,也 没有「按框重复 4 次子框传统」;但要在 一整张(缩略)ROI 图 上跑一套更重的 整图 pipeline。
|
|
||||||
|
|
||||||
对照一句话
|
|
||||||
用黑三角 YOLO(流程 A) 不用黑三角 YOLO(流程 B)
|
|
||||||
Stage2
|
|
||||||
黑三角模型给子框 → 子框内 Otsu + 至多一次 Adaptive
|
|
||||||
无 Stage2 模型
|
|
||||||
三角角点从哪来
|
|
||||||
优先 子框传统;不够再 整图 detect_triangle_markers
|
|
||||||
只有 整图 detect_triangle_markers
|
|
||||||
和「全图是否只做 Adaptive」
|
|
||||||
子框 不是只做 Adaptive;整图回退时也与全图路径一致(先 Otsu 等)
|
|
||||||
整图路径 也不是只做 Adaptive
|
|
||||||
靶环 YOLO(Stage1 裁切)在 A/B 里都可以开或关,与「黑三角模型」是独立开关。
|
|
||||||
|
|
||||||
|
|
||||||
@@ -1,102 +0,0 @@
|
|||||||
|
|
||||||
1. CPP构建命令:在docker环境下执行以下命令
|
|
||||||
|
|
||||||
cd /data/cpp_ext
|
|
||||||
rm -rf build && mkdir build && cd build
|
|
||||||
|
|
||||||
TOOLCHAIN_BIN=/data/MaixCDK-main/dl/extracted/toolchains/maixcam/host-tools/gcc/riscv64-linux-musl-x86_64/bin
|
|
||||||
PYDEV=/data/python3_lib_maixcam_musl_3.11.6
|
|
||||||
MAIXCDK=/data/MaixCDK-main
|
|
||||||
|
|
||||||
cmake .. -G Ninja \
|
|
||||||
-DCMAKE_C_COMPILER="${TOOLCHAIN_BIN}/riscv64-unknown-linux-musl-gcc" \
|
|
||||||
-DCMAKE_CXX_COMPILER="${TOOLCHAIN_BIN}/riscv64-unknown-linux-musl-g++" \
|
|
||||||
-DCMAKE_BUILD_TYPE=Release \
|
|
||||||
-DCMAKE_C_FLAGS="-mcpu=c906fdv -march=rv64imafdcv0p7xthead -mcmodel=medany -mabi=lp64d" \
|
|
||||||
-DCMAKE_CXX_FLAGS="-mcpu=c906fdv -march=rv64imafdcv0p7xthead -mcmodel=medany -mabi=lp64d" \
|
|
||||||
-DPY_INCLUDE_DIR="${PYDEV}/include/python3.11" \
|
|
||||||
-DPY_LIB="${PYDEV}/lib/libpython3.11.so" \
|
|
||||||
-DPY_EXT_SUFFIX=".cpython-311-riscv64-linux-gnu.so" \
|
|
||||||
-DMAIXCDK_PATH="${MAIXCDK}"
|
|
||||||
|
|
||||||
ninja
|
|
||||||
|
|
||||||
|
|
||||||
2. Maixvision 直接跑项目的时候,是复制到板子上的这个目录:/tmp/maixpy_run
|
|
||||||
|
|
||||||
3. 4g 模块的终端测试方法:
|
|
||||||
3.1 一个窗口 ssh 到maixcam的板子上之后,通过 printf 输入命令到 /dev/ttyS2, 然后另外一个窗口通过 cat /dev/ttyS2 输出
|
|
||||||
# 1. 确保 PDP 激活
|
|
||||||
printf 'AT+CGPADDR=1\r\n' > /dev/ttyS2
|
|
||||||
# 2. 开启日志监听(另一个 SSH 窗口)
|
|
||||||
cat /dev/ttyS2
|
|
||||||
# 3. 发送下载命令(原窗口)
|
|
||||||
printf 'AT+MHTTPDLFILE="http://static.shelingxingqiu.com/shoot/v1/main.py","downloaded.py",5120\r\n' > /dev/ttyS2
|
|
||||||
|
|
||||||
4. wifi的启动条件,在 /boot 目录下,看看是否有 wifi.sta 和 wifi.ssid, wifi.pass 这些文件。其中 wifi.sta 是开关文件。
|
|
||||||
如果没有了它就不会启动wifi流程。具体的wifi流程 由 /etc/init.d/S30wifi 控制。它会判断 wifi.sta 是否存在,然后是否启动wifi,还是启动热点。
|
|
||||||
|
|
||||||
5. 给自己的程序打包到基础镜像中,参考:https://wiki.sipeed.com/maixpy/doc/zh/pro/compile_os.html
|
|
||||||
5.1. 按照链接中的步骤,去github上获取了基础镜像,这次使用的是 v4.12.4,把Assets中的下面几样东西下载下来,我是在windows的wsl中执行的,注意,
|
|
||||||
假如是在windows中下载的文件,在wsl中编译会很慢,所以我采用的是直接在wsl中下载,放到wsl的自己的文件系统中。
|
|
||||||
1)maixcam-2025-12-31-maixpy-v4.12.4.img.xz
|
|
||||||
2)maixcam_builtin_files.tar.xz
|
|
||||||
3)MaixPy-4.12.4-py3-none-any.whl
|
|
||||||
4)Source code(zip)
|
|
||||||
5.2. 把自己的文件放到 buildtin_files中:
|
|
||||||
1)我把项目文件目录 t11 放到了 maixcam_builtin_files\maixapp\apps 这个目录下。
|
|
||||||
2)为了能让它自启动,我把 auto_start.txt 放到了 maixcam_builtin_files\maixapp 这个目录下。
|
|
||||||
|
|
||||||
5.3. 然后在解压后的源码中找到tools/os目录下 /home/saga/maixcam/MaixPy-4.12.4/tools/os/maixcam
|
|
||||||
执行
|
|
||||||
export MAIXCDK_PATH=/home/saga/maixcam/MaixCDK
|
|
||||||
编译:
|
|
||||||
./gen_os.sh ../../../../../maixcam/maixcam-2025-12-31-maixpy-v4.12.4.img ../../../../../maixcam/MaixPy-4.12.4-py3-none-any.whl ../../../../../maixcam/maixcam_builtin_files 0 maixcam
|
|
||||||
注意,在编译过程中,也会去 github 下载内容,所以需要打开梯子。
|
|
||||||
5.4. 等待编译完成,会编译成镜像文件,然后根据 https://wiki.sipeed.com/hardware/zh/maixcam/os.html 这个指引来烧录系统。
|
|
||||||
5.5. 烧录完系统后,需要安装 runtime, 可以按照 https://wiki.sipeed.com/maixpy/doc/zh/README_no_screen.html 这个来升级运行库,或者直接在 Maixvision 中链接的时候安装 runtime。
|
|
||||||
5.6. 安装 runtime 之后,重启,我们的系统就会自己启动起来了。
|
|
||||||
|
|
||||||
遇到问题:
|
|
||||||
/mnt/d/code/shooting/compile_maixcam/MaixPy-4.12.4/MaixPy-4.12.4/tools/os/maixcam/fuse2fs: error while loading shared libraries: libfuse.so.2: cannot open shared object file: No such file or directory
|
|
||||||
解决办法:
|
|
||||||
安装 libfuse2
|
|
||||||
sudo apt update
|
|
||||||
sudo apt install libfuse2
|
|
||||||
|
|
||||||
遇到问题:
|
|
||||||
python 缺少 yaml
|
|
||||||
解决办法:
|
|
||||||
pip install pyyaml
|
|
||||||
|
|
||||||
遇到问题:
|
|
||||||
./build_all.sh: line 56: maixtool: command not found
|
|
||||||
解决办法:
|
|
||||||
pip install maixtool
|
|
||||||
|
|
||||||
遇到问题:
|
|
||||||
./update_img.sh: line 80: mcopy: command not found
|
|
||||||
解决办法:
|
|
||||||
sudo apt update
|
|
||||||
sudo apt install mtools
|
|
||||||
|
|
||||||
6. 相机标定:
|
|
||||||
然后在板子上跑 test 目录下的 test_camera_rtsp.py ,让相机启动了一个服务,然后在电脑上接收这个视频流,并且跑opencv 内置的标定程序:
|
|
||||||
set OPENCV_FFMPEG_CAPTURE_OPTIONS="rtsp_transport;tcp"
|
|
||||||
opencv_interactive-calibration -t=chessboard -w=9 -h=6 -sz=0.025 -v="http://192.168.1.81:8000/stream" 2>nul
|
|
||||||
|
|
||||||
|
|
||||||
7. 生成训练图片:在test目录下,执行以下命令。注意,其中 D:\code\shooting\target_photo\write.png 是靶纸的图片。
|
|
||||||
D:\data\test_target_photo 是用来叠加的背景图
|
|
||||||
|
|
||||||
7.1 生成靶纸及黑色三角形的截图的图片,带动动,但1.12的外框
|
|
||||||
bak
|
|
||||||
python .\synth_compose_yolo.py --perspective 0.04 --perspective-prob 0.8 --color-jitter 0.6 --bg-dir D:\data\test_target_photo --fg D:\code\shooting\target_photo\write.png --out ./synth_out --class-name triangle --zip ./maix_dataset.zip --num 60 --triangles-json archery_triangles_default.json --format voc --stage2-crop --stage2-pad-min 0.03 --stage2-pad-max 0.18 --motion-prob 0.9 --motion-kernel-max 8 --blur-max 0 --triangle-bbox-pad-frac 0.12
|
|
||||||
|
|
||||||
bak_2
|
|
||||||
python synth_keypoints_right_angle.py --bg-dir D:\data\test_target_photo --fg D:\code\shooting\target_photo\write.png --triangles-json archery_triangles_default.json --out ./synth_out --num 1000 --offscreen-shift-prob 0.3 --offscreen-shift-frac 0.4 --offscreen-min-visible 1 --stage2-crop --stage2-pad-min 0.03 --stage2-pad-max 0.18 --motion-prob 0.9 --motion-kernel-max 8 --blur-max 0 --perspective-mode planar --yaw-max-deg 10 --pitch-max-deg 8 --roll-max-deg 4 --planar-focal-frac 1.45 --perspective-prob 0.4
|
|
||||||
|
|
||||||
python synth_keypoints_right_angle.py --bg-dir D:\data\test_target_photo --fg D:\code\shooting\target_photo\write.png --triangles-json archery_triangles_default.json --out ./synth_out --num 1000 --offscreen-shift-prob 0.3 --offscreen-shift-frac 0.4 --offscreen-min-visible 1 --stage2-crop --stage2-pad-min 0.03 --stage2-pad-max 0.18 --motion-prob 1.0 --motion-kernel-max 8 --blur-max 0 --perspective-mode planar --yaw-max-deg 10 --pitch-max-deg 8 --roll-max-deg 4 --planar-focal-frac 1.45 --perspective-prob 0.4
|
|
||||||
|
|
||||||
|
|
||||||
python pose_pixel_metrics.py --model D:\code\archery\runs\pose\runs\pose\target_pose_train\weights\best.pt --data D:\code\archery\datasets\dataset_pose.yaml --imgsz 640
|
|
||||||
@@ -1,41 +0,0 @@
|
|||||||
1. 问题描述:开机失败,一直遇到Traceback (most recent call last):
|
|
||||||
File "/tmp/maixpy_run/main.py", line 525, in <module>
|
|
||||||
cmd_str()
|
|
||||||
File "/tmp/maixpy_run/main.py", line 102, in cmd_str
|
|
||||||
camera_manager.init_camera(640, 480)
|
|
||||||
File "/tmp/maixpy_run/camera_manager.py", line 59, in init_camera
|
|
||||||
self._camera = camera.Camera(width, height)
|
|
||||||
^^^^^^^^^^^^^^^^^^^^^^^^^^^^
|
|
||||||
RuntimeError: : Runtime error: mmf vi init failed
|
|
||||||
解决方案:
|
|
||||||
根据过往经验,极有可能是摄像头的接线有问题。因为在测试环境,摄像头是通过一个24针转22针的线出来的,然后再通过一个接线中继,连接到一个22针
|
|
||||||
的fpc线到Maixcam。接线中继如果是24针的,多了两针,需要选好一边然后对连。但这里很容易出错或者松动。可以先用摄像头本身的金色接线直接接到
|
|
||||||
Maixcam,然后跑test目录下的test_cammera.py,看看能不能正常启动,如果正常,就确定是中继接线的问题。
|
|
||||||
|
|
||||||
2. 问题描述:202609 批次的拓展版,在连接 202601 批次的电源板,或者不链接电源板的时候,开机后不久,出错,程序退出,日志是:
|
|
||||||
[v1.2.10] [INFO] network.py:1078 - [NET] TCP主线程启动
|
|
||||||
[v1.2.10] [INFO] network.py:406 - [NET] WiFi不可用或无法连接服务器,使用4G网络
|
|
||||||
[v1.2.10] [INFO] network.py:475 - 连接到服务器,使用4G...
|
|
||||||
[v1.2.10] [INFO] network.py:527 - [4G-TCP] AT+MIPCLOSE=2 response:
|
|
||||||
OK
|
|
||||||
+MIPCLOSE: 2
|
|
||||||
-- [E] read failed
|
|
||||||
Trigger signal, code:SIGSEGV(11)!
|
|
||||||
maix multi-media driver released.
|
|
||||||
ISP Vipipe(0) Free pa(0x8a52c000) va(0x0x3fbeb5e000)
|
|
||||||
program exit failed. exit code: 1.
|
|
||||||
|
|
||||||
解决方案:
|
|
||||||
从日志看,就是开始发送登录信息之后就崩溃了。出发了底层的read failed。经过排查,是一定要插上电源板的数据连线,以及电源板要插上电池。这个应该是登录时需要读电源电压数据。后面我们已经优化了日志,而且增加了对ina226的试探,但发现ina226不存在的时候,就直接返回电压和电流为0.0。而且,一定要注意,在新配套的电源板和核心板上面,才能正常读到电流和电压。
|
|
||||||
|
|
||||||
3. a)问题描述:202609 批次的拓展版,有一块maixcam的蓝灯常亮,询问maixcam的人,他们觉得应该是卡没有插好。但是拓展版上的激光口挡住了数据卡的出口,
|
|
||||||
没法拔出检查,
|
|
||||||
解决方案:需要做拓展版的公司(深链鑫创)在做好板子之后,确定系统能正常启动
|
|
||||||
|
|
||||||
b)问题描述:2022609 批次的拓展板,有一次maixcam的蓝灯亮的时候很长,不会闪烁,后面把sd卡插进去一点,又恢复正常了,初步怀疑是射箭时没有缓冲,
|
|
||||||
导致了sd 卡被撞松了
|
|
||||||
|
|
||||||
4. 问题描述:4G模块不可用,模块的绿灯没有闪亮
|
|
||||||
解决方案:有这样的一种情况,就是4G模块的天线,触碰到了旁边的电容,导致短路,所以模块启动失败。需要保证电容和天线的金属头不会触碰
|
|
||||||
5.
|
|
||||||
|
|
||||||
@@ -1,276 +0,0 @@
|
|||||||
1. 4G OTA 下载的时候,为什么使用十六进制下载,读取 URC 事件?
|
|
||||||
因为使用二进制下载的时候,经常会出现错误,并且会失败?然后最稳定传输的办法,是每次传输的时候,是分块,而且每次分块都要“删/建”http实例。推测原因是因为我们现在是直接传输文件的源代码,代码中含有了一些字符串可能和 AT指令重复,导致了 AT 模块在解释的时候出错。而使用 16 进制的方式,可以避免这个问题。因为十六进制直接把数据先转成了字符串,然后在设备端再把字符串转成数据,这样就不可能出现 AT的指令,从而减少了麻烦。
|
|
||||||
2. 4G OTA 下载的时候,为什么不用 AT 模块里 HTTPDLFILE 的指令?
|
|
||||||
因为在测试中发现,使用 HTTPDLFILE,其实是下载到了 4G 模块内部,需要重新从模块内部转到存储卡,而且 4G 模块的存储较小,大概只有 40k,所以还需要分块来下载和转存,比较麻烦,于是最终使用了使用读取串口事件的模式。
|
|
||||||
3. 4G OTA 下载的时候,为什么不用 AT 模块里 HTTPREAD 的指令?
|
|
||||||
因为之前测试发现,READ模式其实是需要多步:
|
|
||||||
3.1. AT+MHTTPCREATE
|
|
||||||
3.2. AT+MHTTPCFG
|
|
||||||
3.3. AT+MHTTPREQUEST
|
|
||||||
3.4. AT+MHTTPREAD
|
|
||||||
它其实也是把数据下载到 4g 模块的缓存里,然后再从缓存里读取出来。所以也是比较繁琐的,还不如 HTTPDLFILE 简单。
|
|
||||||
4. WiFi OTA 流程(ota_manager.handle_wifi_and_update())
|
|
||||||
* 解析 ota_url 得到 host:port
|
|
||||||
* 调用 network_manager.connect_wifi(ssid, password, verify_host=host, verify_port=port, persist=True)
|
|
||||||
* 只有“能连上 WiFi 且能访问 OTA host:port”才会把新凭证保留在 /boot
|
|
||||||
* 连接成功后开始下载 OTA 文件(download_file())
|
|
||||||
* 下载成功则 apply_ota_and_reboot()
|
|
||||||
5. TCP 通信
|
|
||||||
1) 平时 TCP 通信主流程(network_manager.tcp_main())
|
|
||||||
外层无限循环:一直尝试保持与服务器的 TCP 会话。
|
|
||||||
每轮开始:
|
|
||||||
如果 OTA 正在进行:暂停(避免抢占资源/串口)。
|
|
||||||
connect_server():建立 TCP 连接(自动选 WiFi 或 4G)。
|
|
||||||
发送“登录包”(msg_type=1),等待服务器返回“登录成功”。
|
|
||||||
登录成功后进入内层循环:
|
|
||||||
接收数据:
|
|
||||||
WiFi:非阻塞 recv();没数据返回 b"";有数据进入缓冲区拼包解析。
|
|
||||||
4G:从 ATClient 的队列 pop_tcp_payload() 取数据。
|
|
||||||
处理命令/ACK:
|
|
||||||
登录响应、心跳 ACK、OTA 命令、关机命令、日志上传命令等。
|
|
||||||
发送业务队列:
|
|
||||||
从高优/普通队列取 1 条,发送失败会放回队首,并断线重连(不再丢消息)。
|
|
||||||
发送心跳:
|
|
||||||
按 HEARTBEAT_INTERVAL 发心跳包。
|
|
||||||
心跳失败会计数(当前为连续失败到阈值才重连)。
|
|
||||||
任何发送/接收致命失败:
|
|
||||||
关闭 socket/断开连接 → 跳出内层循环 → 外层等待一会儿后重新 connect_server() → 重新登录。
|
|
||||||
6. “WiFi 连接/验证”
|
|
||||||
TCP 连接建立与网络选择(connect_server() / select_network())
|
|
||||||
* select_network():WiFi 优先,但要求:
|
|
||||||
is_wifi_connected() 为 True(系统层面有 WiFi IP 或 Maix WLAN connected)
|
|
||||||
且能连到 TCP 服务器 SERVER_IP:SERVER_PORT
|
|
||||||
否则回退到 4G
|
|
||||||
* connect_server():
|
|
||||||
若已有连接:WiFi 会做 _check_wifi_connection() 轻量检查;4G 直接认为 OK(由 AT 层维护)。
|
|
||||||
否则按网络类型走:
|
|
||||||
WiFi:创建 socket → connect → setblocking(False)(接收用非阻塞)
|
|
||||||
4G:AT+MIPOPEN 建链
|
|
||||||
WiFi 链接(connect_wifi())
|
|
||||||
当前 connect_wifi() 的关键特点是:必须让 /etc/init.d/S30wifi restart 真正用新 SSID 去连,所以会临时写 /boot/wifi.ssid 和 /boot/wifi.pass,失败自动回滚。
|
|
||||||
流程是:
|
|
||||||
(1) 备份旧配置
|
|
||||||
* /boot/wifi.ssid、/boot/wifi.pass
|
|
||||||
* /etc/wpa_supplicant.conf(尽量备份)
|
|
||||||
(2) 写入新凭证
|
|
||||||
* 把新 ssid/pass 写到 /boot/*
|
|
||||||
-(同时尽量写 /etc/wpa_supplicant.conf,但不强依赖)
|
|
||||||
(3) 重启 WiFi 服务:/etc/init.d/S30wifi restart
|
|
||||||
(4) 等待获取 IP(默认 20 秒,可调)
|
|
||||||
(5) 验证可用性,连到 verify_host:verify_port
|
|
||||||
(6) 成功
|
|
||||||
* persist=True:保留 /boot/*(持久化)
|
|
||||||
* persist=False:回滚 /boot/* 到旧值(不重启,当前连接仍可继续)
|
|
||||||
(7) 失败
|
|
||||||
* 回滚 /boot/* + 回滚 /etc/wpa_supplicant.conf(如果有备份)
|
|
||||||
* 再 S30wifi restart 恢复旧网络
|
|
||||||
* 返回错误
|
|
||||||
|
|
||||||
7. 日志上传(inner_cmd == 43),当前只支持 wifi 上传日志
|
|
||||||
命令带 ssid/password/url 时:
|
|
||||||
* 若 WiFi 未连接:先 connect_wifi(..., verify_host=upload_host, verify_port=upload_port, persist=True)
|
|
||||||
上传内容:
|
|
||||||
* sync # 把日志从内存同步到文件
|
|
||||||
* 快照 app.log* 到 /tmp staging
|
|
||||||
* 打包成 tar.gz(默认)或 zip
|
|
||||||
* 以 multipart/form-data 的 file 字段 POST 到 url
|
|
||||||
|
|
||||||
8. 自动关机:
|
|
||||||
hardware中设定了开停表,然后再增加了获取idle的时间。
|
|
||||||
自动关机的时机: 超过配置的idle时长,
|
|
||||||
禁止自动关机的情况:1.校准中,2.OTA中
|
|
||||||
重启计时的时机:1.校准完成,2.命令触发射箭,3.真实触发射箭,4.初始化完成
|
|
||||||
9. Wifi网络监控:
|
|
||||||
有两次发现wifi网络下,有些消息发送很慢,但具体是什么缘故还不清楚,现在增加了wifi网络下的检测,并一旦发现wifi的网络质量差,就会切换到4G。
|
|
||||||
WiFi 连接成功
|
|
||||||
↓
|
|
||||||
启动后台监测线程
|
|
||||||
↓
|
|
||||||
每 5 秒循环:
|
|
||||||
测量 RTT (1 样本,600ms timeout)
|
|
||||||
获取 RSSI
|
|
||||||
更新缓存
|
|
||||||
判断是否差:
|
|
||||||
- RTT >= 600ms → 差
|
|
||||||
- RTT >= 350ms 且 RSSI <= -80dBm → 差
|
|
||||||
↓
|
|
||||||
如果质量差:
|
|
||||||
快速重试2次,如果其中任意一次网络恢复了,继续使用wifi。否则,
|
|
||||||
调用 _switch_to_4g_due_to_poor_wifi()
|
|
||||||
关闭 WiFi socket
|
|
||||||
重置连接状态
|
|
||||||
尝试切换到 4G
|
|
||||||
↓
|
|
||||||
上层检测到连接断开:
|
|
||||||
重新 connect_server() → 自动选择 4G
|
|
||||||
|
|
||||||
10. 现在使用的相机,其实是支持更大的分辨率的,比如说1920*1280,但是由于我们的图像处理,拍照处理之后很容易触发OOM。
|
|
||||||
|
|
||||||
11. 环数计算流程:
|
|
||||||
现在设备侧的目标是:算出箭点相对靶心的偏移(dx,dy),单位是物理厘米(cm),然后把它作为 x,y 上报给后端;后端再去算环。
|
|
||||||
设备侧本身不直接算环数,它算的是偏移与距离,并上报。
|
|
||||||
|
|
||||||
算法流程(一次射箭从触发到上报)
|
|
||||||
1) 触发后取一帧图
|
|
||||||
在 process_shot() 里读取相机帧并调用 analyze_shot(frame)
|
|
||||||
2) 确定激光点(laser_point)
|
|
||||||
|
|
||||||
analyze_shot() 第一步先确定激光点 (x,y)(像素坐标):
|
|
||||||
|
|
||||||
硬编码:config.HARDCODE_LASER_POINT=True → 用 laser_manager.laser_point
|
|
||||||
已校准:laser_manager.has_calibrated_point() → 用校准值
|
|
||||||
动态模式:先 detect_circle_v3(frame, None) 粗估距离,再根据距离反推激光点
|
|
||||||
代码在:
|
|
||||||
|
|
||||||
if config.HARDCODE_LASER_POINT:
|
|
||||||
...
|
|
||||||
elif laser_manager.has_calibrated_point():
|
|
||||||
...
|
|
||||||
else:
|
|
||||||
_, _, _, _, best_radius1_temp, _ = detect_circle_v3(frame, None)
|
|
||||||
distance_m_first = estimate_distance(best_radius1_temp) ...
|
|
||||||
laser_point = laser_manager.calculate_laser_point_from_distance(distance_m_first)
|
|
||||||
3) 优先走三角形路径(成功就直接用于上报 x/y)
|
|
||||||
如果 config.USE_TRIANGLE_OFFSET=True,先尝试识别靶面四角三角形标记:
|
|
||||||
|
|
||||||
if getattr(config, "USE_TRIANGLE_OFFSET", False):
|
|
||||||
K, dist_coef, pos = _get_triangle_calib()
|
|
||||||
img_rgb = image.image2cv(frame, False, False)
|
|
||||||
tri = try_triangle_scoring(img_rgb, (x, y), pos, K, dist_coef, ...)
|
|
||||||
if tri.get("ok"):
|
|
||||||
return {... "dx": tri["dx_cm"], "dy": tri["dy_cm"], "distance_m": tri.get("distance_m"), ...}
|
|
||||||
这一步里 try_triangle_scoring() 做了两件事(都在 triangle_target.py):
|
|
||||||
|
|
||||||
单应性(homography):把激光点从图像坐标映射到靶面坐标系,得到(dx,dy)(cm)
|
|
||||||
PnP:用识别到的角点与相机标定,估算 相机到靶的距离 distance_m
|
|
||||||
关键代码:
|
|
||||||
|
|
||||||
ok_h, tx, ty, _H = homography_calibration(...)
|
|
||||||
out["dx_cm"] = tx
|
|
||||||
out["dy_cm"] = -ty
|
|
||||||
out["distance_m"] = dist_m
|
|
||||||
out["distance_method"] = "pnp_triangle"
|
|
||||||
注意:这里 dy_cm 取了负号,是为了和现网约定一致(laser_manager.compute_laser_position 的坐标方向)。
|
|
||||||
|
|
||||||
4) 三角形失败 → 回退圆形/椭圆靶心检测(兜底)
|
|
||||||
如果三角形不可用或识别失败,就走传统靶心检测:
|
|
||||||
|
|
||||||
detect_circle_v3(frame, laser_point) 找黄心/红心、半径、椭圆参数
|
|
||||||
用 laser_manager.compute_laser_position() 把像素偏移换算成厘米偏移(dx,dy)
|
|
||||||
在 shoot_manager.py:
|
|
||||||
|
|
||||||
result_img, center, radius, method, best_radius1, ellipse_params = detect_circle_v3(frame, laser_point)
|
|
||||||
if center and radius:
|
|
||||||
dx, dy = laser_manager.compute_laser_position(center, (x, y), radius, method)
|
|
||||||
distance_m = estimate_distance(best_radius1) ...
|
|
||||||
在 laser_manager.compute_laser_position()(核心换算逻辑):
|
|
||||||
|
|
||||||
r = radius * 5
|
|
||||||
target_x = (lx-cx)/r*100
|
|
||||||
target_y = (ly-cy)/r*100
|
|
||||||
return (target_x, -target_y)
|
|
||||||
这里 (像素差)/(radius*5)*100 是你们旧约定下的“像素→厘米”比例模型(并且 y 方向同样取负号)。
|
|
||||||
|
|
||||||
5) 上报数据:把(dx,dy) 作为 x/y 发给后端
|
|
||||||
最终上报发生在 process_shot(),直接把 dx,dy 填到 inner_data["x"],["y"]:
|
|
||||||
|
|
||||||
srv_x = round(float(dx), 4) if dx is not None else 200.0
|
|
||||||
srv_y = round(float(dy), 4) if dy is not None else 200.0
|
|
||||||
inner_data = {
|
|
||||||
"x": srv_x,
|
|
||||||
"y": srv_y,
|
|
||||||
"d": round((distance_m or 0.0) * 100),
|
|
||||||
"m": method if method else "no_target",
|
|
||||||
"offset_method": offset_method,
|
|
||||||
"distance_method": distance_method,
|
|
||||||
...
|
|
||||||
}
|
|
||||||
network_manager.safe_enqueue(...)
|
|
||||||
x,y:物理厘米(cm)
|
|
||||||
d:相机到靶距离(m→cm,乘 100;三角形成功时来自 PnP)
|
|
||||||
m/offset_method/distance_method:标记本次用的算法路径(triangle / yellow / pnp 等)
|
|
||||||
后端收到 x,y 后,再用你之前给的 Go 公式 CalculateRingNumber(x,y,tenRingRadius) 计算环数。
|
|
||||||
|
|
||||||
你现在的“环数计算”实际依赖关系
|
|
||||||
最好路径(快+稳):三角形 → dx,dy(单应性) + distance_m(PnP)
|
|
||||||
兜底路径:圆/椭圆靶心 → dx,dy(基于黄心半径比例/透视校正) + distance_m(黄心半径估距)
|
|
||||||
|
|
||||||
12. 4g模块上传文件:
|
|
||||||
|
|
||||||
Upload images from MaixCam to Qiniu cloud via ML307R 4G module's AT commands. The HTTP body requires multipart/form-data with real CR/LF bytes (0x0D 0x0A) in boundaries.
|
|
||||||
Methods Tried
|
|
||||||
# Method AT Commands Result Root Cause
|
|
||||||
1 Raw binary, no encoding MHTTPCONTENT with raw bytes + length param ERROR at first chunk CR/LF in binary data terminates AT command parser
|
|
||||||
2 Encoding mode 2 (escape) MHTTPCFG="encoding",0,2 + \r\n escapes Server 400 Bad Request Module sends literal text \r\n to server, NOT actual 0x0D 0x0A bytes. Multipart body is garbled
|
|
||||||
3 Encoding mode 1 (hex) MHTTPCFG="encoding",0,1 + hex-encoded data CME ERROR: 650/50 Firmware doesn't properly support hex mode for MHTTPCONTENT
|
|
||||||
4 No chunked mode Skip MHTTPCFG="chunked" CME ERROR: 65 Module requires chunked mode to accept MHTTPCONTENT at all
|
|
||||||
5 Single large MHTTPCONTENT All data in one command (2793 bytes) +MHTTPURC: "err",0,5 (timeout) Possible buffer limit; module hangs then times out
|
|
||||||
6 Per-chunk HTTP instance (OTA style) CREATE→POST→DELETE per chunk Not feasible Each instance = separate HTTP request; Qiniu needs complete body in single POST
|
|
||||||
Conclusion: AT HTTP layer (MHTTPCONTENT) is fundamentally broken for binary uploads.
|
|
||||||
The Solution: Raw TCP Socket (MIPOPEN + MIPSEND)
|
|
||||||
Bypass the AT HTTP layer entirely. Open a raw TCP connection and send a hand-crafted HTTP POST:
|
|
||||||
plaintext
|
|
||||||
AT+MIPCLOSE=3 // Clean up old socket
|
|
||||||
AT+MIPOPEN=3,"TCP","upload.qiniup.com",80 // Raw TCP connection
|
|
||||||
AT+MIPSEND=3,1024 → ">" → [raw bytes] → OK // Binary-safe!
|
|
||||||
AT+MIPSEND=3,1024 → ">" → [raw bytes] → OK
|
|
||||||
AT+MIPSEND=3,766 → ">" → [raw bytes] → OK
|
|
||||||
// Response: +MIPURC: "rtcp",3,<len>,HTTP/1.1 200 OK...
|
|
||||||
AT+MIPCLOSE=3
|
|
||||||
Why it works:
|
|
||||||
MIPSEND enters prompt mode (>) — after the >, the AT parser treats ALL bytes as data, including CR/LF
|
|
||||||
We construct the complete HTTP request ourselves (headers + Content-Length + multipart body) with real CRLF bytes
|
|
||||||
|
|
||||||
Key bug found during integration: _send_chunk() wrapped calls in self.at._cmd_lock, but self.at.send() also acquires the same lock internally — threading.Lock() is not reentrant, causing deadlock. Fixed by removing the outer lock (the network_manager.get_uart_lock() already provides thread safety).Trade-off: UART is locked during the entire upload, so heartbeats pause. For small JPEG files (~2-80KB), this is 5-20 seconds — acceptable if server heartbeat timeout is generous
|
|
||||||
|
|
||||||
|
|
||||||
13. 算环数算法1:「黄心 + 红心」椭圆/圆:主要在 vision.py 的 detect_circle_v3() 里完成:颜色先用 HSV 做掩码,再在轮廓上做面积、圆度筛选,黄圈用椭圆拟合,红圈预先筛成候选,最后用几何关系配对。
|
|
||||||
|
|
||||||
1. 黄色怎么判、范围是什么?
|
|
||||||
图像先转 HSV(cv2.COLOR_RGB2HSV,注意输入是 RGB)。
|
|
||||||
饱和度 S 整体乘 1.1 并限制在 0–255(让黄色更「显」一点)。
|
|
||||||
黄色 inRange(OpenCV HSV,H 多为 0–179):
|
|
||||||
通道 下限 上限
|
|
||||||
H 7 32
|
|
||||||
S 80 255
|
|
||||||
V 0 255
|
|
||||||
在黄掩码上找轮廓后,还要满足:面积 > 50,圆度 > 0.7(circularity = 4π·面积/周长²),且点数 ≥5 才 fitEllipse 当黄心椭圆。
|
|
||||||
|
|
||||||
2. 红色怎么判、范围是什么?
|
|
||||||
红色在 HSV 里跨 0°,所以用 两段 H 做并集:
|
|
||||||
两段分别是:
|
|
||||||
H 0–10,S 80–255,V 0–255
|
|
||||||
H 170–180,S 80–255,V 0–255
|
|
||||||
红轮廓候选:面积 > 50,圆度 > 0.6(比黄略松),再拟合椭圆或最小外接圆得到圆心和半径。
|
|
||||||
|
|
||||||
3. 「黄心」和「红心」怎样算一对?(几何范围)
|
|
||||||
对每个黄圈,在红色候选里找第一个满足:
|
|
||||||
|
|
||||||
两圆心距离 dist_centers < yellow_radius * 1.5
|
|
||||||
红半径 red_radius > yellow_radius * 0.8(红在外圈、略大)
|
|
||||||
dist_centers = math.hypot(ddx, ddy)
|
|
||||||
if dist_centers < yellow_radius * 1.5 and rc["radius"] > yellow_radius * 0.8:
|
|
||||||
小结:黄色 = HSV H∈[7,32]、S≥80(且 S 放大 1.1)+ 形态学闭运算 + 面积/圆度;红色 = 两段 H(0–10 与 170–180)、S≥80 + 闭运算 + 面积/圆度;配对用 同心/包含 的距离与半径比例阈值。若你还关心 laser_manager.py 里「激光红点」的另一套阈值(LASER_*),那是另一条链路,和靶心黄/红 HSV 可以分开看。
|
|
||||||
|
|
||||||
14. 算环数算法2:
|
|
||||||
使用单应性矩阵计算:镜头中心点(照片中心像素)到虚拟平面的转换。它不需要知道相机在 3D 空间中的具体位置,直接通过单应性矩阵 H的逆运算,将 2D 像素“翻译”成虚拟平面上的 2D 坐标。
|
|
||||||
|
|
||||||
一、转换的本质:2D 到 2D 的“查字典”
|
|
||||||
单应性变换(Homography)是平面到平面的映射。它不处理 3D 空间中的“投影线”,而是直接建立图像像素 (u,v) 与虚拟平面坐标 (x,y) 的一一对应关系。
|
|
||||||
你可以把单应性矩阵 H想象成一本“翻译字典”:
|
|
||||||
正变换 H:已知靶纸上的真实位置 (x,y),查字典得到它在照片上哪个像素 (u,v)。
|
|
||||||
逆变换 H−1:已知照片上的像素 (u,v)(如镜头中心点),查字典反推它在靶纸上的真实位置 (x,y)。
|
|
||||||
这个“虚拟平面”就是你的靶纸平面(Z=0 的世界坐标系)。算法没有在物理上移动任何点,只是在做坐标系的换算。
|
|
||||||
|
|
||||||
二、详细步骤:镜头中心点如何“落地”
|
|
||||||
|
|
||||||
相机分辨率是 640x480,镜头中心点(光轴与图像的交点)通常是 (u0,v0)=(320,240)。
|
|
||||||
1. 输入:镜头中心点(像素)
|
|
||||||
2. 核心运算:乘以逆矩阵
|
|
||||||
通过 4 个黑色三角形的角点(已知真实坐标)计算出了单应性矩阵 H。现在使用它的逆矩阵 H−1
|
|
||||||
3. 输出:虚拟平面上的落点(物理坐标)
|
|
||||||
计算后,你会得到:(xhit,yhit)
|
|
||||||
这就是镜头中心点对应的靶纸上的真实位置(单位:毫米)。
|
|
||||||
4. 计算环数
|
|
||||||
由于虚拟平面原点 (0,0)就是靶纸圆心,直接计算欧氏距离。
|
|
||||||
这个 d就是箭着点偏离圆心的真实物理距离,直接用于环数判定。
|
|
||||||
@@ -1,25 +0,0 @@
|
|||||||
你现在要防的是“别人拿到设备/拿到代码包后,能伪造请求、刷接口、下发恶意 OTA、甚至劫持通信”。单靠隐藏 Python 源码只能提高门槛,真正的安全要靠协议和密钥设计。结合你仓库里实际内容,建议你重点隐藏/整改这些点(按风险排序)。
|
|
||||||
|
|
||||||
|
|
||||||
1.4 OTA 下发“url”如果缺少强校验,就是远程代码执行入口
|
|
||||||
你 OTA 逻辑里从服务器指令拿到 url 就去下载并替换文件/重启(这是正常 OTA),但安全性取决于:
|
|
||||||
是否只允许白名单域名/路径
|
|
||||||
是否强制 https 并校验服务器证书
|
|
||||||
是否对 OTA 包做签名校验(最关键)
|
|
||||||
你这里能看到固定域名 static.shelingxingqiu.com 的特殊处理(ota_manager.py 里还在纠结 http/https),这块一定要“服务端签名 + 设备端验签”,否则隐藏源码也没用。
|
|
||||||
|
|
||||||
2. 建议隐藏(中风险,但很容易被人利用)
|
|
||||||
2.1 所有服务器地址/端口/API 路径(可被用于扫描、压测、撞库、协议逆向)
|
|
||||||
这些在 config.py 是明文:
|
|
||||||
SERVER_IP = "stcp.shelingxingqiu.com"SERVER_PORT = 50005HTTP_URL = "http://ws.shelingxingqiu.com"HTTP_API_PATH = "/home/shoot/device_fire/arrow/fire"
|
|
||||||
注意:即使你把它们藏进 .so,攻击者仍可能通过抓包/观察 DNS/SNI/流量拿到域名或 IP,所以“隐藏”只能降低静态分析风险,不能替代鉴权/签名。
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
3. 最有效的“防黑”措施(比隐藏更关键)
|
|
||||||
|
|
||||||
所有上报/控制命令加签名 + nonce/timestamp + 服务端防重放(别人抓到一次包也不能复用)
|
|
||||||
OTA 包必须做签名校验(设备端内置公钥,下载后验签通过才应用)
|
|
||||||
|
|
||||||
如果你告诉我:你们服务端目前能不能改协议(例如新增签名字段、下发 challenge、做 OTA 签名),我可以按“最小改动但提升最大安全”的顺序,帮你规划一套从现状平滑升级的方案。
|
|
||||||
|
|
||||||
@@ -1,52 +0,0 @@
|
|||||||
import os
|
|
||||||
|
|
||||||
|
|
||||||
def generate_key_pair():
|
|
||||||
"""
|
|
||||||
生成一对新的密钥a和b,使得a XOR b等于原始key
|
|
||||||
:return: (a, b, key) 元组,每个元素都是32字节的字节数组
|
|
||||||
"""
|
|
||||||
# 原始key值
|
|
||||||
key = bytes([
|
|
||||||
0x5d, 0xf9, 0xef, 0xc4, 0x5d, 0xcc, 0xc7, 0x8d, 0xc9, 0x86, 0x34, 0x11, 0x6f, 0xb4, 0xcf, 0x75,
|
|
||||||
0xbf, 0x24, 0x47, 0x9d, 0xd6, 0x5d, 0x83, 0x4b, 0xa6, 0xc0, 0xde, 0x27, 0x91, 0x92, 0xb1, 0x63
|
|
||||||
])
|
|
||||||
|
|
||||||
# 随机生成a
|
|
||||||
a = os.urandom(32)
|
|
||||||
|
|
||||||
# 计算b = key XOR a
|
|
||||||
b = bytes([key[i] ^ a[i] for i in range(32)])
|
|
||||||
|
|
||||||
return a, b, key
|
|
||||||
|
|
||||||
|
|
||||||
def format_hex_array(data):
|
|
||||||
"""
|
|
||||||
将字节数组格式化为C++风格的十六进制数组
|
|
||||||
:param data: 字节数组
|
|
||||||
:return: 格式化后的字符串
|
|
||||||
"""
|
|
||||||
return "{" + ",".join([f"0x{b:02x}" for b in data]) + "}"
|
|
||||||
|
|
||||||
|
|
||||||
def generate_new_key_pair():
|
|
||||||
"""
|
|
||||||
生成新的密钥对并打印出来
|
|
||||||
"""
|
|
||||||
a, b, key = generate_key_pair()
|
|
||||||
|
|
||||||
print("原始key:")
|
|
||||||
print(format_hex_array(key))
|
|
||||||
print("\n新的密钥对:")
|
|
||||||
print("a =", format_hex_array(a))
|
|
||||||
print("b =", format_hex_array(b))
|
|
||||||
|
|
||||||
# 验证a XOR b是否等于key
|
|
||||||
verify_key = bytes([a[i] ^ b[i] for i in range(32)])
|
|
||||||
assert verify_key == key, "验证失败:a XOR b 不等于 key"
|
|
||||||
print("\n验证成功:a XOR b 等于 key")
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
generate_new_key_pair()
|
|
||||||
@@ -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
|
||||||
+39
-20
@@ -54,8 +54,8 @@ class LaserManager:
|
|||||||
@property
|
@property
|
||||||
def laser_point(self):
|
def laser_point(self):
|
||||||
"""当前激光点(如果启用硬编码,则返回硬编码值)"""
|
"""当前激光点(如果启用硬编码,则返回硬编码值)"""
|
||||||
if config.HARDCODE_LASER_POINT:
|
# if config.HARDCODE_LASER_POINT:
|
||||||
return config.HARDCODE_LASER_POINT_VALUE
|
# return config.HARDCODE_LASER_POINT_VALUE
|
||||||
return self._laser_point
|
return self._laser_point
|
||||||
|
|
||||||
def get_last_frame_with_ellipse(self):
|
def get_last_frame_with_ellipse(self):
|
||||||
@@ -102,31 +102,28 @@ class LaserManager:
|
|||||||
# ==================== 业务方法 ====================
|
# ==================== 业务方法 ====================
|
||||||
|
|
||||||
def load_laser_point(self):
|
def load_laser_point(self):
|
||||||
"""从配置文件加载激光中心点,失败则使用默认值
|
"""加载激光中心点:优先使用本地保存的坐标,其次硬编码值,最后默认值"""
|
||||||
如果启用硬编码模式,则直接使用硬编码值
|
# 优先:从本地持久化文件加载(由 cmd 201 保存)
|
||||||
"""
|
|
||||||
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
|
|
||||||
|
|
||||||
# 正常模式:从配置文件加载
|
|
||||||
try:
|
try:
|
||||||
if "laser_config.json" in os.listdir("/root"):
|
if "laser_config.json" in os.listdir("/root"):
|
||||||
with open(config.CONFIG_FILE, "r") as f:
|
with open(config.CONFIG_FILE, "r") as f:
|
||||||
data = json.load(f)
|
data = json.load(f)
|
||||||
if isinstance(data, list) and len(data) == 2:
|
if isinstance(data, list) and len(data) == 2:
|
||||||
self._laser_point = (int(data[0]), int(data[1]))
|
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
|
return self._laser_point
|
||||||
else:
|
except Exception:
|
||||||
raise ValueError
|
pass
|
||||||
else:
|
|
||||||
self._laser_point = config.DEFAULT_LASER_POINT
|
# 其次:硬编码值
|
||||||
except:
|
if config.HARDCODE_LASER_POINT:
|
||||||
self._laser_point = config.DEFAULT_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
|
return self._laser_point
|
||||||
|
|
||||||
def save_laser_point(self, point):
|
def save_laser_point(self, point):
|
||||||
@@ -1264,6 +1261,28 @@ class LaserManager:
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
self.logger.error(f"[LASER] 关闭激光失败: {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()
|
laser_manager = LaserManager()
|
||||||
|
|||||||
+2
-2
@@ -65,8 +65,8 @@ class LoggerManager:
|
|||||||
backup_count = config.LOG_BACKUP_COUNT
|
backup_count = config.LOG_BACKUP_COUNT
|
||||||
|
|
||||||
try:
|
try:
|
||||||
# 创建日志队列(无界队列)
|
# 创建日志队列(有界队列,防止内存泄漏;满时自动丢弃旧日志)
|
||||||
self._log_queue = queue.Queue(-1)
|
self._log_queue = queue.Queue(maxsize=config.LOG_QUEUE_MAXSIZE)
|
||||||
|
|
||||||
# 确保日志文件所在的目录存在
|
# 确保日志文件所在的目录存在
|
||||||
log_dir = os.path.dirname(log_file)
|
log_dir = os.path.dirname(log_file)
|
||||||
|
|||||||
@@ -122,7 +122,7 @@ def cmd_str():
|
|||||||
|
|
||||||
# 1. 初始化日志系统
|
# 1. 初始化日志系统
|
||||||
import logging
|
import logging
|
||||||
logger_manager.init_logging(log_level=logging.DEBUG)
|
logger_manager.init_logging(log_level=logging.WARNING)
|
||||||
logger = logger_manager.logger
|
logger = logger_manager.logger
|
||||||
|
|
||||||
# 补充:因为初始化的时候,激光会亮,先关了它
|
# 补充:因为初始化的时候,激光会亮,先关了它
|
||||||
@@ -283,41 +283,34 @@ def cmd_str():
|
|||||||
|
|
||||||
pressure_buf = []
|
pressure_buf = []
|
||||||
pressure_sum = 0
|
pressure_sum = 0
|
||||||
pressure_abs_sum = 0
|
|
||||||
pressure_min = 4095
|
pressure_min = 4095
|
||||||
pressure_max = 0
|
pressure_max = 0
|
||||||
pressure_t0_ms = None
|
pressure_t0_ms = None
|
||||||
last_avg_abs = 0
|
|
||||||
|
|
||||||
def _flush_pressure_buf(reason: str):
|
def _flush_pressure_buf(reason: str):
|
||||||
if not config.AIR_PRESSURE_lOG:
|
nonlocal pressure_buf, pressure_sum, pressure_min, pressure_max, pressure_t0_ms, logger
|
||||||
return
|
|
||||||
nonlocal pressure_buf, pressure_sum, pressure_min, pressure_max, pressure_t0_ms, logger, pressure_abs_sum, last_avg_abs
|
|
||||||
if not pressure_buf:
|
if not pressure_buf:
|
||||||
return
|
return
|
||||||
t1_ms = time.ticks_ms()
|
if config.AIR_PRESSURE_lOG:
|
||||||
n = len(pressure_buf)
|
t1_ms = time.ticks_ms()
|
||||||
avg = (pressure_sum / n) if n else 0
|
n = len(pressure_buf)
|
||||||
avg_abs = (pressure_abs_sum / n) if n else 0
|
avg = (pressure_sum / n) if n else 0
|
||||||
# 一行输出:方便后处理画曲线;同时带上统计信息便于快速看波峰
|
line = (
|
||||||
line = (
|
f"[气压批量] reason={reason} "
|
||||||
f"[气压批量] reason={reason} "
|
f"t0={pressure_t0_ms} t1={t1_ms} n={n} "
|
||||||
f"t0={pressure_t0_ms} t1={t1_ms} n={n} "
|
f"min={pressure_min} max={pressure_max} avg={avg:.1f} "
|
||||||
f"min={pressure_min} max={pressure_max} avg={avg:.1f} avg_abs={avg_abs:.3f} "
|
f"values={','.join(map(str, pressure_buf))}"
|
||||||
f"values={','.join(map(str, pressure_buf))}"
|
)
|
||||||
f" convert value (kpa): {(max(pressure_buf, key=lambda x: x[1])[1] - last_avg_abs) / (5 - 2.5) * config.AIR_PRESSURE_HARDWARE_MAX:.1f}"
|
if logger:
|
||||||
)
|
logger.debug(line)
|
||||||
if logger:
|
else:
|
||||||
logger.debug(line)
|
print(line)
|
||||||
else:
|
# 无论是否记录日志,都必须清空 buffer,否则内存泄漏
|
||||||
print(line)
|
|
||||||
pressure_buf = []
|
pressure_buf = []
|
||||||
pressure_sum = 0
|
pressure_sum = 0
|
||||||
pressure_abs_sum = 0
|
|
||||||
pressure_min = 4095
|
pressure_min = 4095
|
||||||
pressure_max = 0
|
pressure_max = 0
|
||||||
pressure_t0_ms = None
|
pressure_t0_ms = None
|
||||||
last_avg_abs = avg_abs
|
|
||||||
|
|
||||||
# 主循环:检测扳机触发 → 拍照 → 分析 → 上报
|
# 主循环:检测扳机触发 → 拍照 → 分析 → 上报
|
||||||
while not app.need_exit():
|
while not app.need_exit():
|
||||||
@@ -336,6 +329,7 @@ def cmd_str():
|
|||||||
time.sleep_ms(250)
|
time.sleep_ms(250)
|
||||||
continue
|
continue
|
||||||
|
|
||||||
|
# todo 去除或者不在这里检测
|
||||||
# 不在 OTA 状态下,检测是否空闲足够长,自动关机
|
# 不在 OTA 状态下,检测是否空闲足够长,自动关机
|
||||||
# print(f"[MAIN] 空闲时间: {hardware_manager.get_idle_time_in_sec() }秒")
|
# print(f"[MAIN] 空闲时间: {hardware_manager.get_idle_time_in_sec() }秒")
|
||||||
# print(f"配置关机时间:{config.AUTO_POWER_OFF_IN_SECONDS} 秒")
|
# print(f"配置关机时间:{config.AUTO_POWER_OFF_IN_SECONDS} 秒")
|
||||||
@@ -352,12 +346,10 @@ def cmd_str():
|
|||||||
if network_manager.manual_trigger_flag:
|
if network_manager.manual_trigger_flag:
|
||||||
network_manager.clear_manual_trigger()
|
network_manager.clear_manual_trigger()
|
||||||
adc_val = config.ADC_TRIGGER_THRESHOLD + 1
|
adc_val = config.ADC_TRIGGER_THRESHOLD + 1
|
||||||
adc_abs_val = 10
|
|
||||||
if logger:
|
if logger:
|
||||||
logger.info("[TEST] TCP命令触发射箭")
|
logger.info("[TEST] TCP命令触发射箭")
|
||||||
else:
|
else:
|
||||||
adc_val = hardware_manager.adc_obj.read()
|
adc_val = hardware_manager.adc_obj.read()
|
||||||
adc_abs_val = hardware_manager.adc_obj.read_vol()
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger = logger_manager.logger
|
logger = logger_manager.logger
|
||||||
if logger:
|
if logger:
|
||||||
@@ -368,9 +360,8 @@ def cmd_str():
|
|||||||
# ====== 气压采样缓存(每次循环都记录,批量输出日志)======
|
# ====== 气压采样缓存(每次循环都记录,批量输出日志)======
|
||||||
if pressure_t0_ms is None:
|
if pressure_t0_ms is None:
|
||||||
pressure_t0_ms = current_time
|
pressure_t0_ms = current_time
|
||||||
pressure_buf.append((adc_val, adc_abs_val))
|
pressure_buf.append(adc_val)
|
||||||
pressure_sum += adc_val
|
pressure_sum += adc_val
|
||||||
pressure_abs_sum += adc_abs_val
|
|
||||||
if adc_val < pressure_min:
|
if adc_val < pressure_min:
|
||||||
pressure_min = adc_val
|
pressure_min = adc_val
|
||||||
if adc_val > pressure_max:
|
if adc_val > pressure_max:
|
||||||
|
|||||||
Binary file not shown.
@@ -1,13 +0,0 @@
|
|||||||
|
|
||||||
[basic]
|
|
||||||
type = cvimodel
|
|
||||||
model = model_270139.cvimodel
|
|
||||||
|
|
||||||
[extra]
|
|
||||||
model_type = yolov5
|
|
||||||
input_type = rgb
|
|
||||||
mean = 0, 0, 0
|
|
||||||
scale = 0.00392156862745098, 0.00392156862745098, 0.00392156862745098
|
|
||||||
anchors = 10, 13, 16, 30, 33, 23, 30, 61, 62, 45, 59, 119, 116, 90, 156, 198, 373, 326
|
|
||||||
labels = 黑三角和圆环
|
|
||||||
|
|
||||||
Binary file not shown.
@@ -1,13 +0,0 @@
|
|||||||
|
|
||||||
[basic]
|
|
||||||
type = cvimodel
|
|
||||||
model = model_270820.cvimodel
|
|
||||||
|
|
||||||
[extra]
|
|
||||||
model_type = yolov5
|
|
||||||
input_type = rgb
|
|
||||||
mean = 0, 0, 0
|
|
||||||
scale = 0.00392156862745098, 0.00392156862745098, 0.00392156862745098
|
|
||||||
anchors = 10, 13, 16, 30, 33, 23, 30, 61, 62, 45, 59, 119, 116, 90, 156, 198, 373, 326
|
|
||||||
labels = triangle
|
|
||||||
|
|
||||||
+642
-217
File diff suppressed because it is too large
Load Diff
+57
@@ -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
|
||||||
-230
@@ -1,230 +0,0 @@
|
|||||||
#!/usr/bin/env python3
|
|
||||||
# -*- coding: utf-8 -*-
|
|
||||||
"""
|
|
||||||
应用打包脚本
|
|
||||||
根据 app.yaml 中列出的文件,打包成 zip 文件
|
|
||||||
版本号从 version.py 中读取
|
|
||||||
"""
|
|
||||||
import argparse
|
|
||||||
import os
|
|
||||||
import yaml
|
|
||||||
import zipfile
|
|
||||||
from datetime import datetime
|
|
||||||
import sys
|
|
||||||
import secrets
|
|
||||||
|
|
||||||
MAGIC = b"AROTAE1" # 7 bytes: Archery OTA Encrypted v1
|
|
||||||
GCM_NONCE_LEN = 12
|
|
||||||
GCM_TAG_LEN = 16
|
|
||||||
|
|
||||||
# 添加当前目录到路径,以便导入 version 模块
|
|
||||||
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
|
||||||
|
|
||||||
|
|
||||||
def load_app_yaml(yaml_path='app.yaml'):
|
|
||||||
"""加载 app.yaml 文件"""
|
|
||||||
try:
|
|
||||||
with open(yaml_path, 'r', encoding='utf-8') as f:
|
|
||||||
return yaml.safe_load(f)
|
|
||||||
except Exception as e:
|
|
||||||
print(f"[ERROR] 读取 {yaml_path} 失败: {e}")
|
|
||||||
return None
|
|
||||||
|
|
||||||
|
|
||||||
def check_files_exist(files, base_dir='.'):
|
|
||||||
"""检查文件是否存在"""
|
|
||||||
missing_files = []
|
|
||||||
existing_files = []
|
|
||||||
|
|
||||||
for file_path in files:
|
|
||||||
full_path = os.path.join(base_dir, file_path)
|
|
||||||
if os.path.exists(full_path):
|
|
||||||
existing_files.append(file_path)
|
|
||||||
else:
|
|
||||||
missing_files.append(file_path)
|
|
||||||
|
|
||||||
return existing_files, missing_files
|
|
||||||
|
|
||||||
|
|
||||||
def get_version_from_version_py():
|
|
||||||
"""从 version.py 读取版本号"""
|
|
||||||
try:
|
|
||||||
from version import VERSION
|
|
||||||
return VERSION
|
|
||||||
except ImportError:
|
|
||||||
print("[WARNING] 无法导入 version.py,使用默认版本号 1.0.0")
|
|
||||||
return '1.0.0'
|
|
||||||
except Exception as e:
|
|
||||||
print(f"[WARNING] 读取 version.py 失败: {e},使用默认版本号 1.0.0")
|
|
||||||
return '1.0.0'
|
|
||||||
|
|
||||||
|
|
||||||
def create_zip_package(app_info, files, output_dir='.', base_dir='.'):
|
|
||||||
"""创建 zip 打包文件"""
|
|
||||||
# 生成输出文件名:{name}_v{version}_{timestamp}.zip
|
|
||||||
# 版本号从 version.py 读取,而不是从 app.yaml
|
|
||||||
app_name = app_info.get('name', 'app')
|
|
||||||
version = get_version_from_version_py() # 从 version.py 读取版本号
|
|
||||||
timestamp = datetime.now().strftime('%Y%m%d_%H%M%S')
|
|
||||||
zip_filename = f"{app_name}_v{version}_{timestamp}.zip"
|
|
||||||
zip_path = os.path.join(output_dir, zip_filename)
|
|
||||||
|
|
||||||
print(f"[INFO] 开始打包: {zip_filename}")
|
|
||||||
print(f"[INFO] 包含文件数: {len(files)}")
|
|
||||||
|
|
||||||
try:
|
|
||||||
with zipfile.ZipFile(zip_path, 'w', zipfile.ZIP_DEFLATED) as zipf:
|
|
||||||
for file_path in files:
|
|
||||||
full_path = os.path.join(base_dir, file_path)
|
|
||||||
# 使用相对路径作为 zip 内的路径
|
|
||||||
zipf.write(full_path, file_path)
|
|
||||||
print(f" ✓ {file_path}")
|
|
||||||
|
|
||||||
# 获取文件大小
|
|
||||||
file_size = os.path.getsize(zip_path)
|
|
||||||
file_size_mb = file_size / (1024 * 1024)
|
|
||||||
|
|
||||||
print(f"\n[SUCCESS] 打包完成!")
|
|
||||||
print(f" 文件名: {zip_filename}")
|
|
||||||
print(f" 文件大小: {file_size_mb:.2f} MB ({file_size:,} 字节)")
|
|
||||||
print(f" 文件路径: {os.path.abspath(zip_path)}")
|
|
||||||
|
|
||||||
return zip_path
|
|
||||||
except Exception as e:
|
|
||||||
print(f"[ERROR] 打包失败: {e}")
|
|
||||||
import traceback
|
|
||||||
traceback.print_exc()
|
|
||||||
return None
|
|
||||||
|
|
||||||
|
|
||||||
def _validate_key_hex(key_hex: str) -> bytes:
|
|
||||||
if not isinstance(key_hex, str):
|
|
||||||
raise ValueError("aead key must be hex string")
|
|
||||||
key_hex = key_hex.strip().lower()
|
|
||||||
if key_hex.startswith("0x"):
|
|
||||||
key_hex = key_hex[2:]
|
|
||||||
if len(key_hex) != 64:
|
|
||||||
raise ValueError("aead key must be 64 hex chars (32 bytes)")
|
|
||||||
try:
|
|
||||||
key = bytes.fromhex(key_hex)
|
|
||||||
except Exception as e:
|
|
||||||
raise ValueError(f"invalid hex key: {e}")
|
|
||||||
if len(key) != 32:
|
|
||||||
raise ValueError("aead key must be 32 bytes")
|
|
||||||
return key
|
|
||||||
|
|
||||||
|
|
||||||
def encrypt_zip_aead(zip_path: str, key_hex: str, out_ext: str = ".enc") -> str:
|
|
||||||
"""
|
|
||||||
Encrypt the whole zip file as one blob:
|
|
||||||
output format: MAGIC(7) | nonce(12) | ciphertext(N) | tag(16)
|
|
||||||
using AES-256-GCM (AEAD).
|
|
||||||
"""
|
|
||||||
# Lazy import: packaging-only dependency
|
|
||||||
try:
|
|
||||||
from cryptography.hazmat.primitives.ciphers.aead import AESGCM
|
|
||||||
except Exception as e:
|
|
||||||
raise RuntimeError(
|
|
||||||
"Missing dependency: cryptography. Install with: pip install cryptography. "
|
|
||||||
f"Import error: {e}"
|
|
||||||
)
|
|
||||||
|
|
||||||
key = _validate_key_hex(key_hex)
|
|
||||||
with open(zip_path, "rb") as f:
|
|
||||||
plain = f.read()
|
|
||||||
|
|
||||||
nonce = secrets.token_bytes(GCM_NONCE_LEN)
|
|
||||||
aesgcm = AESGCM(key)
|
|
||||||
ct_and_tag = aesgcm.encrypt(nonce, plain, None) # ciphertext || tag (16 bytes)
|
|
||||||
|
|
||||||
enc_path = zip_path + out_ext if out_ext else (zip_path + ".enc")
|
|
||||||
with open(enc_path, "wb") as f:
|
|
||||||
f.write(MAGIC)
|
|
||||||
f.write(nonce)
|
|
||||||
f.write(ct_and_tag)
|
|
||||||
|
|
||||||
return enc_path
|
|
||||||
|
|
||||||
|
|
||||||
def main():
|
|
||||||
"""主函数"""
|
|
||||||
parser = argparse.ArgumentParser(description="打包 app.yaml 文件列表到 zip,并可选进行 AES-256-GCM 加密输出 .enc")
|
|
||||||
parser.add_argument("--aead-key-hex", default=None, help="AES-256-GCM key (64 hex chars = 32 bytes). If set, output encrypted file.")
|
|
||||||
parser.add_argument("--keep-zip", action="store_true", help="Keep the plaintext zip when encryption is enabled.")
|
|
||||||
parser.add_argument("--out-ext", default=".enc", help="Encrypted output extension appended to zip path. Default: .enc (produces *.zip.enc)")
|
|
||||||
args = parser.parse_args()
|
|
||||||
|
|
||||||
print("=" * 60)
|
|
||||||
print("应用打包脚本")
|
|
||||||
print("=" * 60)
|
|
||||||
|
|
||||||
# 1. 加载 app.yaml
|
|
||||||
app_info = load_app_yaml('app.yaml')
|
|
||||||
if app_info is None:
|
|
||||||
return
|
|
||||||
|
|
||||||
# 从 version.py 读取版本号
|
|
||||||
version = get_version_from_version_py()
|
|
||||||
|
|
||||||
print(f"\n[INFO] 应用信息:")
|
|
||||||
print(f" ID: {app_info.get('id', 'N/A')}")
|
|
||||||
print(f" 名称: {app_info.get('name', 'N/A')}")
|
|
||||||
print(f" 版本: {version} (来自 version.py)")
|
|
||||||
print(f" 作者: {app_info.get('author', 'N/A')}")
|
|
||||||
if app_info.get('version') != version:
|
|
||||||
print(f" [注意] app.yaml 中的版本 ({app_info.get('version', 'N/A')}) 与 version.py 不一致")
|
|
||||||
|
|
||||||
# 2. 获取文件列表
|
|
||||||
files = app_info.get('files', [])
|
|
||||||
if not files:
|
|
||||||
print("[ERROR] app.yaml 中没有找到 files 列表")
|
|
||||||
return
|
|
||||||
|
|
||||||
print(f"\n[INFO] 文件列表 ({len(files)} 个文件):")
|
|
||||||
|
|
||||||
# 3. 检查文件是否存在
|
|
||||||
existing_files, missing_files = check_files_exist(files)
|
|
||||||
|
|
||||||
if missing_files:
|
|
||||||
print(f"\n[WARNING] 以下文件不存在,将被跳过:")
|
|
||||||
for f in missing_files:
|
|
||||||
print(f" ✗ {f}")
|
|
||||||
|
|
||||||
if not existing_files:
|
|
||||||
print("\n[ERROR] 没有找到任何有效文件,无法打包")
|
|
||||||
return
|
|
||||||
|
|
||||||
print(f"\n[INFO] 找到 {len(existing_files)} 个有效文件")
|
|
||||||
|
|
||||||
# 4. 创建 zip 包
|
|
||||||
zip_path = create_zip_package(app_info, existing_files)
|
|
||||||
|
|
||||||
if zip_path:
|
|
||||||
enc_path = None
|
|
||||||
if args.aead_key_hex:
|
|
||||||
try:
|
|
||||||
enc_path = encrypt_zip_aead(zip_path, args.aead_key_hex, out_ext=args.out_ext)
|
|
||||||
enc_size = os.path.getsize(enc_path)
|
|
||||||
print(f"\n[SUCCESS] AEAD加密完成: {os.path.basename(enc_path)} ({enc_size:,} bytes)")
|
|
||||||
print(f" 文件路径: {os.path.abspath(enc_path)}")
|
|
||||||
if not args.keep_zip:
|
|
||||||
try:
|
|
||||||
os.remove(zip_path)
|
|
||||||
print(f"[INFO] 已删除明文zip: {os.path.basename(zip_path)}")
|
|
||||||
except Exception as e:
|
|
||||||
print(f"[WARNING] 删除明文zip失败(可忽略): {e}")
|
|
||||||
except Exception as e:
|
|
||||||
print(f"\n[ERROR] AEAD加密失败: {e}")
|
|
||||||
print("[ERROR] 保留明文zip用于排查。")
|
|
||||||
|
|
||||||
print("\n" + "=" * 60)
|
|
||||||
print("打包成功完成!")
|
|
||||||
print("=" * 60)
|
|
||||||
else:
|
|
||||||
print("\n" + "=" * 60)
|
|
||||||
print("打包失败!")
|
|
||||||
print("=" * 60)
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
main()
|
|
||||||
@@ -5,6 +5,8 @@
|
|||||||
提供电压、电流监测和充电状态检测
|
提供电压、电流监测和充电状态检测
|
||||||
"""
|
"""
|
||||||
import config
|
import config
|
||||||
|
import os
|
||||||
|
import subprocess
|
||||||
from logger_manager import logger_manager
|
from logger_manager import logger_manager
|
||||||
from maix import time as maix_time
|
from maix import time as maix_time
|
||||||
|
|
||||||
@@ -85,7 +87,7 @@ def get_bus_voltage():
|
|||||||
def get_current():
|
def get_current():
|
||||||
"""
|
"""
|
||||||
读取电流(单位:mA)
|
读取电流(单位:mA)
|
||||||
正数表示充电,负数表示放电
|
当前电源板实测:正数表示放电,负数表示充电。
|
||||||
|
|
||||||
INA226 电流计算公式:
|
INA226 电流计算公式:
|
||||||
Current = (Current Register Value) × Current_LSB
|
Current = (Current Register Value) × Current_LSB
|
||||||
@@ -96,13 +98,13 @@ def get_current():
|
|||||||
return 0.0
|
return 0.0
|
||||||
raw = read_register(config.REG_CURRENT)
|
raw = read_register(config.REG_CURRENT)
|
||||||
# INA226 电流寄存器是16位有符号整数
|
# INA226 电流寄存器是16位有符号整数
|
||||||
# 最高位是符号位:0=正(充电),1=负(放电)
|
# 最高位是符号位;电流方向含义取决于电源板的采样电阻接线方向。
|
||||||
# 计算 Current_LSB(根据 CALIBRATION_VALUE)
|
# 计算 Current_LSB(根据 CALIBRATION_VALUE)
|
||||||
current_lsb = 0.001 * config.CALIBRATION_VALUE / 4096 # 单位:A
|
current_lsb = 0.001 * config.CALIBRATION_VALUE / 4096 # 单位:A
|
||||||
# 处理有符号数:如果最高位为1,转换为负数
|
# 处理有符号数:如果最高位为1,转换为负数
|
||||||
if raw & 0x8000: # 最高位为1,表示负数(放电)
|
if raw & 0x8000:
|
||||||
signed_raw = raw - 0x10000 # 转换为有符号整数
|
signed_raw = raw - 0x10000 # 转换为有符号整数
|
||||||
else: # 最高位为0,表示正数(充电)
|
else:
|
||||||
signed_raw = raw
|
signed_raw = raw
|
||||||
# 转换为毫安
|
# 转换为毫安
|
||||||
current_ma = signed_raw * current_lsb * 1000
|
current_ma = signed_raw * current_lsb * 1000
|
||||||
@@ -129,7 +131,7 @@ def is_charging(threshold_ma=10.0):
|
|||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
current = get_current()
|
current = get_current()
|
||||||
is_charge = current > threshold_ma
|
is_charge = current < -abs(float(threshold_ma))
|
||||||
return is_charge
|
return is_charge
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger = logger_manager.logger
|
logger = logger_manager.logger
|
||||||
|
|||||||
@@ -1,57 +0,0 @@
|
|||||||
import configparser, os
|
|
||||||
|
|
||||||
def parse_apps_info():
|
|
||||||
info_path = "/maixapp/apps/app.info"
|
|
||||||
conf = configparser.ConfigParser()
|
|
||||||
conf.read(info_path)
|
|
||||||
version = conf["basic"]["version"]
|
|
||||||
apps = {}
|
|
||||||
for id in list(conf.keys()):
|
|
||||||
if id in ["basic", "DEFAULT"]:
|
|
||||||
continue
|
|
||||||
apps[id] = conf[id]
|
|
||||||
return apps
|
|
||||||
|
|
||||||
def list_apps():
|
|
||||||
apps = parse_apps_info()
|
|
||||||
print(f"APP num: {len(apps)}")
|
|
||||||
for i, (id, info) in enumerate(apps.items()):
|
|
||||||
name_zh = info.get("name[zh]", "")
|
|
||||||
print(f"{i + 1}. [{info['name']}] {name_zh}:")
|
|
||||||
print(f" id: {id}")
|
|
||||||
print(f" exec: {info['exec']}")
|
|
||||||
print(f" author: {info['author']}")
|
|
||||||
print(f" desc: {info['desc']}")
|
|
||||||
print(f" desc_zh: {info.get('desc', 'None')}")
|
|
||||||
print("")
|
|
||||||
|
|
||||||
|
|
||||||
def get_curr_autostart_app():
|
|
||||||
path = "/maixapp/auto_start.txt"
|
|
||||||
if os.path.exists(path):
|
|
||||||
with open(path, "r") as f:
|
|
||||||
app_id = f.readline().strip()
|
|
||||||
return app_id
|
|
||||||
return None
|
|
||||||
|
|
||||||
def set_autostart_app(app_id):
|
|
||||||
path = "/maixapp/auto_start.txt"
|
|
||||||
if not app_id:
|
|
||||||
if os.path.exists(path):
|
|
||||||
os.remove(path)
|
|
||||||
return
|
|
||||||
with open(path, "w") as f:
|
|
||||||
f.write(app_id)
|
|
||||||
os.sync()
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
new_autostart_app_id = "t11" # change to app_id you want to set
|
|
||||||
# new_autostart_app_id = None # remove autostart
|
|
||||||
# new_autostart_app_id = "z1222" # change to app_id you want to set
|
|
||||||
|
|
||||||
list_apps()
|
|
||||||
print("Before set autostart appid:", get_curr_autostart_app())
|
|
||||||
set_autostart_app(new_autostart_app_id)
|
|
||||||
print("Current autostart appid:", get_curr_autostart_app())
|
|
||||||
|
|
||||||
|
|
||||||
+1
-1
@@ -320,8 +320,8 @@ def process_shot(adc_val):
|
|||||||
logger = logger_manager.logger
|
logger = logger_manager.logger
|
||||||
|
|
||||||
try:
|
try:
|
||||||
network_manager.safe_enqueue({"shoot_event": "start"}, msg_type=2, high=True)
|
|
||||||
frame = camera_manager.read_frame()
|
frame = camera_manager.read_frame()
|
||||||
|
network_manager.safe_enqueue({"shoot_event": "start"}, msg_type=2, high=True)
|
||||||
|
|
||||||
# 调用算法分析
|
# 调用算法分析
|
||||||
analysis_result = analyze_shot(frame)
|
analysis_result = analyze_shot(frame)
|
||||||
|
|||||||
@@ -1,50 +0,0 @@
|
|||||||
# test_audio.pyx
|
|
||||||
from maix import audio, time, app, gpio
|
|
||||||
|
|
||||||
def run_player_loop():
|
|
||||||
"""
|
|
||||||
播放控制主循环函数
|
|
||||||
"""
|
|
||||||
# 初始化音频播放器
|
|
||||||
p = audio.Player("/root/gun.wav")
|
|
||||||
p.volume(40)
|
|
||||||
|
|
||||||
# 初始化 GPIO 引脚为输出
|
|
||||||
led = gpio.GPIO("A25", gpio.Mode.OUT)
|
|
||||||
# 设置低电平
|
|
||||||
led.value(0)
|
|
||||||
|
|
||||||
# 主循环
|
|
||||||
while not app.need_exit():
|
|
||||||
led.value(1) # 点亮 LED
|
|
||||||
time.sleep_ms(200) # 保持 200ms
|
|
||||||
led.value(0) # 熄灭 LED
|
|
||||||
p.play() # 播放音频
|
|
||||||
time.sleep_ms(1000) # 等待 1 秒
|
|
||||||
|
|
||||||
print("play finish!")
|
|
||||||
|
|
||||||
|
|
||||||
# 可选:添加一个简单的测试函数
|
|
||||||
def hello():
|
|
||||||
return "Hello from test_audio!"
|
|
||||||
|
|
||||||
|
|
||||||
# 可选:添加一个初始化函数
|
|
||||||
def init_led():
|
|
||||||
"""单独测试 GPIO"""
|
|
||||||
led = gpio.GPIO("A25", gpio.Mode.OUT)
|
|
||||||
led.value(0)
|
|
||||||
return "LED initialized"
|
|
||||||
|
|
||||||
|
|
||||||
# 可选:添加一个播放测试函数
|
|
||||||
def test_play():
|
|
||||||
"""单独测试音频播放"""
|
|
||||||
p = audio.Player("/root/gun.wav")
|
|
||||||
p.volume(50)
|
|
||||||
p.play()
|
|
||||||
return "Playing..."
|
|
||||||
|
|
||||||
|
|
||||||
run_player_loop()
|
|
||||||
@@ -1,25 +0,0 @@
|
|||||||
from maix import audio, time, app,gpio
|
|
||||||
|
|
||||||
|
|
||||||
# button1 = gpio.GPIO("ADC", gpio.Mode.IN)
|
|
||||||
button3 = gpio.GPIO("A26", gpio.Mode.IN) # 可用
|
|
||||||
button2 = gpio.GPIO("A16", gpio.Mode.IN)
|
|
||||||
#设置低电平
|
|
||||||
from maix.peripheral import adc
|
|
||||||
channel = 0
|
|
||||||
res_bit = adc.RES_BIT_12
|
|
||||||
_adc_obj = adc.ADC(channel, res_bit)
|
|
||||||
|
|
||||||
|
|
||||||
while not app.need_exit():
|
|
||||||
# print(f"b1: {button1.value()}")
|
|
||||||
|
|
||||||
print(f"b2: {button2.value()}")
|
|
||||||
|
|
||||||
# print(_adc_obj.read_vol())
|
|
||||||
print(f"b3: {button3.value()}")
|
|
||||||
time.sleep_ms(50)
|
|
||||||
|
|
||||||
# time.sleep_ms(1000)
|
|
||||||
|
|
||||||
|
|
||||||
@@ -1,36 +0,0 @@
|
|||||||
# from maix import time, rtsp, camera, image
|
|
||||||
|
|
||||||
# # 1. 初始化摄像头(注意:RTSP需要NV21格式)
|
|
||||||
# # 分辨率可以根据需要调整,如 640x480 或 1280x720
|
|
||||||
# cam = camera.Camera(640, 480, image.Format.FMT_YVU420SP)
|
|
||||||
|
|
||||||
# # 2. 创建并启动RTSP服务器
|
|
||||||
# server = rtsp.Rtsp()
|
|
||||||
# server.bind_camera(cam)
|
|
||||||
# server.start()
|
|
||||||
|
|
||||||
# # 3. 打印出访问地址,例如: rtsp://192.168.xxx.xxx:8554/live
|
|
||||||
# print("RTSP 流地址:", server.get_url())
|
|
||||||
|
|
||||||
# # 4. 保持服务运行
|
|
||||||
# while True:
|
|
||||||
# time.sleep(1)
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
from maix import camera, time, app, http, image
|
|
||||||
|
|
||||||
# 初始化相机,注意格式要用 FMT_RGB888(JPEG 编码需要 RGB 输入)
|
|
||||||
cam = camera.Camera(640, 480, image.Format.FMT_RGB888)
|
|
||||||
|
|
||||||
# 创建 JPEG 流服务器
|
|
||||||
stream = http.JpegStreamer()
|
|
||||||
stream.start()
|
|
||||||
|
|
||||||
print("RTSP 替代方案 - HTTP JPEG 流地址: http://{}:{}".format(stream.host(), stream.port()))
|
|
||||||
print("请在浏览器或 OpenCV 中访问: http://<MaixCAM_IP>:8000/stream")
|
|
||||||
|
|
||||||
while not app.need_exit():
|
|
||||||
img = cam.read()
|
|
||||||
jpg = img.to_jpeg() # 将 RGB 图像编码为 JPEG
|
|
||||||
stream.write(jpg) # 推送到 HTTP 客户端
|
|
||||||
@@ -1,20 +0,0 @@
|
|||||||
# test_camera.py
|
|
||||||
from maix import camera, display, time
|
|
||||||
|
|
||||||
try:
|
|
||||||
print("Initializing camera...")
|
|
||||||
cam = camera.Camera(640,480)
|
|
||||||
# cam = camera.Camera(1280,720)
|
|
||||||
# cam.get_exposure_us()
|
|
||||||
# print("Camera exposure: ", cam.get_exposure_us())
|
|
||||||
print("Camera initialized successfully!")
|
|
||||||
|
|
||||||
disp = display.Display()
|
|
||||||
|
|
||||||
while True:
|
|
||||||
frame = cam.read()
|
|
||||||
disp.show(frame)
|
|
||||||
time.sleep_ms(50)
|
|
||||||
|
|
||||||
except Exception as e:
|
|
||||||
print(f"Error: {e}")
|
|
||||||
@@ -1,620 +0,0 @@
|
|||||||
#!/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
|
|
||||||
|
|
||||||
logger = get_logger()
|
|
||||||
if area > 50 and circularity > 0.7:
|
|
||||||
if logger:
|
|
||||||
logger.info(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-10度(接近0度的红色)
|
|
||||||
lower_red1 = np.array([0, 80, 0])
|
|
||||||
upper_red1 = np.array([10, 255, 255])
|
|
||||||
mask_red1 = cv2.inRange(hsv, lower_red1, upper_red1)
|
|
||||||
|
|
||||||
# 红色范围2: 170-180度(接近180度的红色)
|
|
||||||
lower_red2 = np.array([170, 80, 0])
|
|
||||||
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)
|
|
||||||
|
|
||||||
# 形态学操作
|
|
||||||
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)
|
|
||||||
|
|
||||||
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
|
|
||||||
|
|
||||||
# 红色圆圈也应该有一定的圆度
|
|
||||||
if area_red > 50 and circularity_red > 0.6:
|
|
||||||
# 计算红色圆圈的中心和半径
|
|
||||||
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)
|
|
||||||
|
|
||||||
# 圆心距离阈值:应该小于黄色半径的某个倍数(比如1.5倍)
|
|
||||||
max_distance = yellow_radius * 1.5
|
|
||||||
|
|
||||||
# 红色圆圈应该比黄色圆圈大(外圈)
|
|
||||||
if distance < max_distance and red_radius > yellow_radius * 0.8:
|
|
||||||
found_valid_red = True
|
|
||||||
logger = get_logger()
|
|
||||||
if logger:
|
|
||||||
logger.info(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:
|
|
||||||
logger = get_logger()
|
|
||||||
if logger:
|
|
||||||
logger.debug("Debug -> 未找到匹配的红色圆圈,可能是误识别")
|
|
||||||
|
|
||||||
# 从所有有效目标中选择最佳目标
|
|
||||||
if valid_targets:
|
|
||||||
if laser_point:
|
|
||||||
# 如果有激光点,选择最接近激光点的目标
|
|
||||||
best_target = None
|
|
||||||
min_distance = float('inf')
|
|
||||||
for target in valid_targets:
|
|
||||||
dx = target['center'][0] - laser_point[0]
|
|
||||||
dy = target['center'][1] - laser_point[1]
|
|
||||||
distance = np.sqrt(dx*dx + dy*dy)
|
|
||||||
if distance < min_distance:
|
|
||||||
min_distance = distance
|
|
||||||
best_target = target
|
|
||||||
if best_target:
|
|
||||||
best_center = best_target['center']
|
|
||||||
best_radius = best_target['radius']
|
|
||||||
ellipse_params = best_target['ellipse']
|
|
||||||
method = "v3_ellipse_red_validated_laser_selected"
|
|
||||||
best_radius1 = best_radius * 5
|
|
||||||
else:
|
|
||||||
# 如果没有激光点,选择面积最大的目标
|
|
||||||
best_target = max(valid_targets, key=lambda t: t['area'])
|
|
||||||
best_center = best_target['center']
|
|
||||||
best_radius = best_target['radius']
|
|
||||||
ellipse_params = best_target['ellipse']
|
|
||||||
method = "v3_ellipse_red_validated"
|
|
||||||
best_radius1 = best_radius * 5
|
|
||||||
|
|
||||||
result_img = image.cv2image(img_cv, False, False)
|
|
||||||
return result_img, best_center, best_radius, method, best_radius1, ellipse_params
|
|
||||||
|
|
||||||
def detect_circle(frame):
|
|
||||||
"""检测图像中的靶心(优先清晰轮廓,其次黄色区域)"""
|
|
||||||
img_cv = image.image2cv(frame, False, False)
|
|
||||||
# gray = cv2.cvtColor(img_cv, cv2.COLOR_RGB2GRAY)
|
|
||||||
# blurred = cv2.GaussianBlur(gray, (5, 5), 0)
|
|
||||||
# edged = cv2.Canny(blurred, 50, 150)
|
|
||||||
# kernel = cv2.getStructuringElement(cv2.MORPH_ELLIPSE, (5, 5))
|
|
||||||
# ceroded = cv2.erode(cv2.dilate(edged, kernel), kernel)
|
|
||||||
|
|
||||||
# contours, _ = cv2.findContours(ceroded, cv2.RETR_TREE, cv2.CHAIN_APPROX_SIMPLE)
|
|
||||||
# best_center = best_radius = best_radius1 = method = None
|
|
||||||
|
|
||||||
# hsv = cv2.cvtColor(img_cv, cv2.COLOR_RGB2HSV)
|
|
||||||
# h, s, v = cv2.split(hsv)
|
|
||||||
# s = np.clip(s * 2, 0, 255).astype(np.uint8)
|
|
||||||
# hsv = cv2.merge((h, s, v))
|
|
||||||
# lower_yellow = np.array([7, 80, 0])
|
|
||||||
# upper_yellow = np.array([32, 255, 182])
|
|
||||||
# mask = cv2.inRange(hsv, lower_yellow, upper_yellow)
|
|
||||||
# kernel = cv2.getStructuringElement(cv2.MORPH_ELLIPSE, (5, 5))
|
|
||||||
# mask = cv2.morphologyEx(mask, cv2.MORPH_OPEN, kernel)
|
|
||||||
# mask = cv2.morphologyEx(mask, cv2.MORPH_DILATE, kernel)
|
|
||||||
# contours, _ = cv2.findContours(mask, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE)
|
|
||||||
# if contours:
|
|
||||||
# largest = max(contours, key=cv2.contourArea)
|
|
||||||
# if cv2.contourArea(largest) > 50:
|
|
||||||
# (x, y), radius = cv2.minEnclosingCircle(largest)
|
|
||||||
# best_center = (int(x), int(y))
|
|
||||||
# best_radius = int(radius)
|
|
||||||
# best_radius1 = radius * 5
|
|
||||||
# method = "v2"
|
|
||||||
|
|
||||||
# auto
|
|
||||||
# R:31 M:v2 D:2.410110127692767
|
|
||||||
# hsv = cv2.cvtColor(img_cv, cv2.COLOR_RGB2HSV)
|
|
||||||
# h, s, v = cv2.split(hsv)
|
|
||||||
|
|
||||||
# # 1. 增强饱和度(模糊照片需要更强的增强)
|
|
||||||
# s = np.clip(s * 2.5, 0, 255).astype(np.uint8) # 从2.0改为2.5
|
|
||||||
|
|
||||||
# # 2. 增强亮度(模糊照片可能偏暗)
|
|
||||||
# v = np.clip(v * 1.2, 0, 255).astype(np.uint8) # 新增:提升亮度
|
|
||||||
|
|
||||||
# hsv = cv2.merge((h, s, v))
|
|
||||||
|
|
||||||
# # 3. 放宽HSV颜色范围(特别是模糊照片)
|
|
||||||
# # 降低饱和度下限,提高亮度上限
|
|
||||||
# lower_yellow = np.array([5, 50, 30]) # H:5-35, S:50-255, V:30-255
|
|
||||||
# upper_yellow = np.array([35, 255, 255])
|
|
||||||
|
|
||||||
# mask = cv2.inRange(hsv, lower_yellow, upper_yellow)
|
|
||||||
|
|
||||||
# # 4. 增强形态学操作(连接被分割的区域)
|
|
||||||
# kernel_small = cv2.getStructuringElement(cv2.MORPH_ELLIPSE, (5, 5))
|
|
||||||
# kernel_large = cv2.getStructuringElement(cv2.MORPH_ELLIPSE, (9, 9)) # 更大的核
|
|
||||||
|
|
||||||
# # 先开运算去除噪声
|
|
||||||
# mask = cv2.morphologyEx(mask, cv2.MORPH_OPEN, kernel_small)
|
|
||||||
# # 多次膨胀连接区域(模糊照片需要更多膨胀)
|
|
||||||
# mask = cv2.dilate(mask, kernel_large, iterations=2) # 增加迭代次数
|
|
||||||
# mask = cv2.morphologyEx(mask, cv2.MORPH_CLOSE, kernel_large) # 闭运算填充空洞
|
|
||||||
|
|
||||||
# contours, _ = cv2.findContours(mask, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE)
|
|
||||||
# if contours:
|
|
||||||
# largest = max(contours, key=cv2.contourArea)
|
|
||||||
# area = cv2.contourArea(largest)
|
|
||||||
# if area > 50:
|
|
||||||
# # 5. 使用面积计算等效半径(更准确)
|
|
||||||
# equivalent_radius = np.sqrt(area / np.pi)
|
|
||||||
|
|
||||||
# # 6. 同时使用minEnclosingCircle作为备选(取较大值)
|
|
||||||
# (x, y), enclosing_radius = cv2.minEnclosingCircle(largest)
|
|
||||||
|
|
||||||
# # 取两者中的较大值,确保不遗漏
|
|
||||||
# radius = max(equivalent_radius, enclosing_radius)
|
|
||||||
|
|
||||||
# best_center = (int(x), int(y))
|
|
||||||
# best_radius = int(radius)
|
|
||||||
# best_radius1 = radius * 5
|
|
||||||
# method = "v2"
|
|
||||||
|
|
||||||
# codegee
|
|
||||||
# R:24 M:v2 D:3.061493895819174
|
|
||||||
# R:22 M:v2 D:3.3644971681267077 np.clip(s * 1.1, 0, 255)
|
|
||||||
hsv = cv2.cvtColor(img_cv, cv2.COLOR_RGB2HSV)
|
|
||||||
h, s, v = cv2.split(hsv)
|
|
||||||
|
|
||||||
# 2. 调整饱和度策略:
|
|
||||||
# 不要暴力翻倍,可以尝试稍微增强,或者使用 CLAHE 增强亮度/对比度
|
|
||||||
# 这里我们稍微增加一点饱和度,并确保不溢出
|
|
||||||
s = np.clip(s * 1.1, 0, 255).astype(np.uint8)
|
|
||||||
# 对亮度通道 v 也可以做一点 CLAHE 处理来增强对比度(可选)
|
|
||||||
# clahe = cv2.createCLAHE(clipLimit=2.0, tileGridSize=(8,8))
|
|
||||||
# v = clahe.apply(v)
|
|
||||||
|
|
||||||
hsv = cv2.merge((h, s, v))
|
|
||||||
|
|
||||||
# 3. 放宽 HSV 阈值范围(针对模糊图像的关键调整)
|
|
||||||
# 降低 S 的下限 (80 -> 35),提高 V 的上限 (182 -> 255)
|
|
||||||
lower_yellow = np.array([7, 80, 0]) # 饱和度下限降低,捕捉淡黄色
|
|
||||||
upper_yellow = np.array([32, 255, 255]) # 亮度上限拉满
|
|
||||||
|
|
||||||
mask = cv2.inRange(hsv, lower_yellow, upper_yellow)
|
|
||||||
|
|
||||||
# 4. 调整形态学操作
|
|
||||||
# 去掉 MORPH_OPEN,因为它会减小面积。
|
|
||||||
# 使用 MORPH_CLOSE (先膨胀后腐蚀) 来填充内部小黑洞,连接近邻区域
|
|
||||||
kernel = cv2.getStructuringElement(cv2.MORPH_ELLIPSE, (5, 5))
|
|
||||||
mask = cv2.morphologyEx(mask, cv2.MORPH_CLOSE, kernel)
|
|
||||||
# 再进行一次膨胀,确保边缘被包含进来
|
|
||||||
# mask = cv2.dilate(mask, kernel, iterations=1)
|
|
||||||
|
|
||||||
contours, _ = cv2.findContours(mask, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE)
|
|
||||||
|
|
||||||
if contours:
|
|
||||||
largest = max(contours, key=cv2.contourArea)
|
|
||||||
|
|
||||||
# 这里可以适当降低面积阈值,或者保持不变
|
|
||||||
if cv2.contourArea(largest) > 50:
|
|
||||||
# (x, y), radius = cv2.minEnclosingCircle(largest)
|
|
||||||
# best_center = (int(x), int(y))
|
|
||||||
# best_radius = int(radius)
|
|
||||||
|
|
||||||
# --- 核心修改开始 ---
|
|
||||||
# 1. 尝试拟合椭圆 (需要轮廓点至少为5个)
|
|
||||||
if len(largest) >= 5:
|
|
||||||
# 返回值: ((中心x, 中心y), (长轴, 短轴), 旋转角度)
|
|
||||||
(x, y), (axes_major, axes_minor), angle = cv2.fitEllipse(largest)
|
|
||||||
|
|
||||||
# 2. 计算半径
|
|
||||||
# 选项A:取长短轴的平均值 (比较稳健)
|
|
||||||
# radius = (axes_major + axes_minor) / 4
|
|
||||||
|
|
||||||
# 选项B:直接取短轴的一半 (抗模糊最强,推荐)
|
|
||||||
radius = axes_minor / 2
|
|
||||||
|
|
||||||
best_center = (int(x), int(y))
|
|
||||||
best_radius = int(radius)
|
|
||||||
method = "v2_ellipse"
|
|
||||||
else:
|
|
||||||
# 如果点太少无法拟合椭圆,降级回 minEnclosingCircle
|
|
||||||
(x, y), radius = cv2.minEnclosingCircle(largest)
|
|
||||||
best_center = (int(x), int(y))
|
|
||||||
best_radius = int(radius)
|
|
||||||
method = "v2"
|
|
||||||
# --- 核心修改结束 ---
|
|
||||||
|
|
||||||
# 你的后续逻辑
|
|
||||||
best_radius1 = radius * 5
|
|
||||||
|
|
||||||
|
|
||||||
# operas 4.5
|
|
||||||
# R:25 M:v2 D:2.9554872521538527
|
|
||||||
# hsv = cv2.cvtColor(img_cv, cv2.COLOR_RGB2HSV)
|
|
||||||
# h, s, v = cv2.split(hsv)
|
|
||||||
|
|
||||||
# # 1. 适度增强饱和度(不要过度,否则噪声也会增强)
|
|
||||||
# s = np.clip(s * 1.5, 0, 255).astype(np.uint8)
|
|
||||||
# hsv = cv2.merge((h, s, v))
|
|
||||||
|
|
||||||
# # 2. 放宽 HSV 阈值范围(关键改动)
|
|
||||||
# # - 饱和度下限从 80 降到 40(捕捉淡黄色)
|
|
||||||
# # - 亮度上限从 182 提高到 255(允许更亮的黄色)
|
|
||||||
# lower_yellow = np.array([7, 40, 30])
|
|
||||||
# upper_yellow = np.array([35, 255, 255])
|
|
||||||
|
|
||||||
# mask = cv2.inRange(hsv, lower_yellow, upper_yellow)
|
|
||||||
|
|
||||||
# # 3. 调整形态学操作:用 CLOSE 替代 OPEN
|
|
||||||
# # CLOSE(先膨胀后腐蚀):填充内部空洞,连接相邻区域
|
|
||||||
# # OPEN(先腐蚀后膨胀):会缩小区域,不适合模糊图像
|
|
||||||
# kernel = cv2.getStructuringElement(cv2.MORPH_ELLIPSE, (7, 7)) # 稍大的核
|
|
||||||
# mask = cv2.morphologyEx(mask, cv2.MORPH_CLOSE, kernel)
|
|
||||||
# mask = cv2.dilate(mask, kernel, iterations=1) # 额外膨胀,确保边缘被包含
|
|
||||||
|
|
||||||
# contours, _ = cv2.findContours(mask, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE)
|
|
||||||
# if contours:
|
|
||||||
# largest = max(contours, key=cv2.contourArea)
|
|
||||||
# if cv2.contourArea(largest) > 50:
|
|
||||||
# (x, y), radius = cv2.minEnclosingCircle(largest)
|
|
||||||
# best_center = (int(x), int(y))
|
|
||||||
# best_radius = int(radius)
|
|
||||||
# best_radius1 = radius * 5
|
|
||||||
# method = "v2"
|
|
||||||
|
|
||||||
# # --- 新增:将 Mask 叠加到原图上用于调试 ---
|
|
||||||
# # 创建一个彩色掩码(红色通道为255,其他为0)
|
|
||||||
# mask_overlay = np.zeros_like(img_cv)
|
|
||||||
# mask_overlay[:, :, 2] = mask # 将掩码放在红色通道 (BGR中的R)
|
|
||||||
#
|
|
||||||
# cv2.addWeighted(img_cv, 0.6, mask_overlay, 0.4, 0, img_cv)
|
|
||||||
|
|
||||||
result_img = image.cv2image(img_cv, False, False)
|
|
||||||
return result_img, best_center, best_radius, method, best_radius1
|
|
||||||
|
|
||||||
|
|
||||||
def detect_circle_v2(frame):
|
|
||||||
"""检测图像中的靶心(优先清晰轮廓,其次黄色区域)- 返回椭圆参数版本"""
|
|
||||||
global REAL_RADIUS_CM
|
|
||||||
img_cv = image.image2cv(frame, False, False)
|
|
||||||
|
|
||||||
best_center = best_radius = best_radius1 = method = None
|
|
||||||
ellipse_params = None # 存储椭圆参数 ((x, y), (axes_major, axes_minor), angle)
|
|
||||||
|
|
||||||
# HSV 黄色掩码检测(模糊靶心)
|
|
||||||
hsv = cv2.cvtColor(img_cv, cv2.COLOR_RGB2HSV)
|
|
||||||
h, s, v = cv2.split(hsv)
|
|
||||||
|
|
||||||
# 调整饱和度策略:稍微增强,不要过度
|
|
||||||
s = np.clip(s * 1.1, 0, 255).astype(np.uint8)
|
|
||||||
|
|
||||||
hsv = cv2.merge((h, s, v))
|
|
||||||
|
|
||||||
# 放宽 HSV 阈值范围(针对模糊图像的关键调整)
|
|
||||||
lower_yellow = np.array([7, 80, 0]) # 饱和度下限降低,捕捉淡黄色
|
|
||||||
upper_yellow = np.array([32, 255, 255]) # 亮度上限拉满
|
|
||||||
|
|
||||||
mask = cv2.inRange(hsv, lower_yellow, upper_yellow)
|
|
||||||
|
|
||||||
# 调整形态学操作
|
|
||||||
# 使用 MORPH_CLOSE (先膨胀后腐蚀) 来填充内部小黑洞,连接近邻区域
|
|
||||||
kernel = cv2.getStructuringElement(cv2.MORPH_ELLIPSE, (5, 5))
|
|
||||||
mask = cv2.morphologyEx(mask, cv2.MORPH_CLOSE, kernel)
|
|
||||||
|
|
||||||
contours, _ = cv2.findContours(mask, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE)
|
|
||||||
|
|
||||||
if contours:
|
|
||||||
largest = max(contours, key=cv2.contourArea)
|
|
||||||
|
|
||||||
if cv2.contourArea(largest) > 50:
|
|
||||||
# 尝试拟合椭圆 (需要轮廓点至少为5个)
|
|
||||||
if len(largest) >= 5:
|
|
||||||
# 返回值: ((中心x, 中心y), (width, height), 旋转角度)
|
|
||||||
# 注意:width 和 height 是外接矩形的尺寸,不是长轴和短轴
|
|
||||||
(x, y), (width, height), angle = cv2.fitEllipse(largest)
|
|
||||||
|
|
||||||
# 保存椭圆参数(保持原始顺序,用于绘制)
|
|
||||||
ellipse_params = ((x, y), (width, height), angle)
|
|
||||||
|
|
||||||
# 计算半径:使用较小的尺寸作为短轴
|
|
||||||
axes_minor = min(width, height)
|
|
||||||
radius = axes_minor / 2
|
|
||||||
|
|
||||||
best_center = (int(x), int(y))
|
|
||||||
best_radius = int(radius)
|
|
||||||
method = "v2_ellipse"
|
|
||||||
else:
|
|
||||||
# 如果点太少无法拟合椭圆,降级回 minEnclosingCircle
|
|
||||||
(x, y), radius = cv2.minEnclosingCircle(largest)
|
|
||||||
best_center = (int(x), int(y))
|
|
||||||
best_radius = int(radius)
|
|
||||||
method = "v2"
|
|
||||||
ellipse_params = None # 圆形,没有椭圆参数
|
|
||||||
|
|
||||||
best_radius1 = radius * 5
|
|
||||||
|
|
||||||
result_img = image.cv2image(img_cv, False, False)
|
|
||||||
return result_img, best_center, best_radius, method, best_radius1, ellipse_params
|
|
||||||
|
|
||||||
# ==================== 测试逻辑 ====================
|
|
||||||
|
|
||||||
def run_offline_test(image_path):
|
|
||||||
"""读取图片,检测圆,绘制结果,保存图片"""
|
|
||||||
|
|
||||||
# 1. 检查文件是否存在
|
|
||||||
if not os.path.exists(image_path):
|
|
||||||
print(f"[ERROR] 找不到图片文件: {image_path}")
|
|
||||||
return
|
|
||||||
|
|
||||||
# 2. 使用 maix.image 读取图片 (适配 MaixPy v4)
|
|
||||||
try:
|
|
||||||
# 使用 image.load 读取文件,返回 Image 对象
|
|
||||||
img = image.load(image_path)
|
|
||||||
print(f"[INFO] 成功读取图片: {image_path} (尺寸: {img.width()}x{img.height()})")
|
|
||||||
except Exception as e:
|
|
||||||
print(f"[ERROR] 读取图片失败: {e}")
|
|
||||||
print("提示:请确认 MaixPy 版本是否为 v4,且图片路径正确。")
|
|
||||||
return
|
|
||||||
|
|
||||||
|
|
||||||
# 3. 调用 detect_circle_v2 函数
|
|
||||||
print("[INFO] 正在调用 detect_circle_v2 进行检测...")
|
|
||||||
start_time = time.ticks_ms()
|
|
||||||
|
|
||||||
result_img, center, radius, method, radius1, ellipse_params = detect_circle_v3(img)
|
|
||||||
|
|
||||||
cost_time = time.ticks_ms() - start_time
|
|
||||||
print(f"[INFO] 检测完成,耗时: {cost_time}ms")
|
|
||||||
print(f" 结果 -> 圆心: {center}, 半径: {radius}, 方法: {method}")
|
|
||||||
if ellipse_params:
|
|
||||||
(ell_center, (width, height), angle) = ellipse_params
|
|
||||||
print(f" 椭圆 -> 中心: ({ell_center[0]:.1f}, {ell_center[1]:.1f}), 长轴: {max(width, height):.1f}, 短轴: {min(width, height):.1f}, 角度: {angle:.1f}°")
|
|
||||||
|
|
||||||
# 4. 绘制辅助线(可选,用于调试)
|
|
||||||
if center and radius:
|
|
||||||
# 为了绘制椭圆,需要转换回 cv2 图像
|
|
||||||
img_cv = image.image2cv(result_img, False, False)
|
|
||||||
|
|
||||||
cx, cy = center
|
|
||||||
|
|
||||||
# 如果有椭圆参数,绘制椭圆
|
|
||||||
if ellipse_params:
|
|
||||||
(ell_center, (width, height), angle) = ellipse_params
|
|
||||||
cx_ell, cy_ell = int(ell_center[0]), int(ell_center[1])
|
|
||||||
|
|
||||||
# 确定长轴和短轴
|
|
||||||
if width >= height:
|
|
||||||
# width 是长轴,height 是短轴
|
|
||||||
axes_major = width
|
|
||||||
axes_minor = height
|
|
||||||
major_angle = angle # 长轴角度就是 angle
|
|
||||||
minor_angle = angle + 90 # 短轴角度 = 长轴角度 + 90度
|
|
||||||
else:
|
|
||||||
# height 是长轴,width 是短轴
|
|
||||||
axes_major = height
|
|
||||||
axes_minor = width
|
|
||||||
major_angle = angle + 90 # 长轴角度 = width角度 + 90度
|
|
||||||
minor_angle = angle # 短轴角度就是 angle
|
|
||||||
|
|
||||||
# 使用 OpenCV 绘制椭圆(绿色,线宽2)
|
|
||||||
cv2.ellipse(img_cv,
|
|
||||||
(cx_ell, cy_ell), # 中心点
|
|
||||||
(int(width/2), int(height/2)), # 半宽、半高
|
|
||||||
angle, # 旋转角度(OpenCV需要原始angle)
|
|
||||||
0, 360, # 起始和结束角度
|
|
||||||
(0, 255, 0), # 绿色 (RGB格式)
|
|
||||||
2) # 线宽
|
|
||||||
|
|
||||||
# 绘制椭圆中心点(红色)
|
|
||||||
cv2.circle(img_cv, (cx_ell, cy_ell), 3, (255, 0, 0), -1)
|
|
||||||
|
|
||||||
import math
|
|
||||||
# 绘制短轴(蓝色线条)
|
|
||||||
minor_length = axes_minor / 2
|
|
||||||
minor_angle_rad = math.radians(minor_angle)
|
|
||||||
dx_minor = minor_length * math.cos(minor_angle_rad)
|
|
||||||
dy_minor = minor_length * math.sin(minor_angle_rad)
|
|
||||||
pt1_minor = (int(cx_ell - dx_minor), int(cy_ell - dy_minor))
|
|
||||||
pt2_minor = (int(cx_ell + dx_minor), int(cy_ell + dy_minor))
|
|
||||||
cv2.line(img_cv, pt1_minor, pt2_minor, (0, 0, 255), 2) # 蓝色 (RGB格式)
|
|
||||||
else:
|
|
||||||
# 如果没有椭圆参数,绘制圆形(红色)
|
|
||||||
cv2.circle(img_cv, (cx, cy), radius, (0, 0, 255), 2)
|
|
||||||
cv2.circle(img_cv, (cx, cy), 2, (0, 0, 255), -1)
|
|
||||||
|
|
||||||
# 转换回 maix image
|
|
||||||
result_img = image.cv2image(img_cv, False, False)
|
|
||||||
|
|
||||||
# 定义颜色对象用于文字
|
|
||||||
try:
|
|
||||||
color_black = image.Color.from_rgb(0,0,0)
|
|
||||||
except AttributeError:
|
|
||||||
color_black = image.Color(0,0,0)
|
|
||||||
|
|
||||||
# D. 添加文字信息
|
|
||||||
FOCAL_LENGTH_PIX = 1900
|
|
||||||
d = (REAL_RADIUS_CM * FOCAL_LENGTH_PIX) / radius1 / 100.0
|
|
||||||
info_str = f"R:{radius} M:{method} D:{d:.2f}"
|
|
||||||
print(info_str)
|
|
||||||
|
|
||||||
# 计算文字位置,防止超出图片边界
|
|
||||||
r_outer = int(radius * 11.0) if radius else 100
|
|
||||||
text_y = cy - r_outer - 20 if cy > r_outer + 20 else cy + r_outer + 20
|
|
||||||
|
|
||||||
# 调用 draw_string
|
|
||||||
result_img.draw_string(0, 0, info_str, color=color_black, scale=1.0)
|
|
||||||
|
|
||||||
|
|
||||||
# 5. 保存结果图片
|
|
||||||
output_path = image_path.replace(".bmp", "_result.bmp")
|
|
||||||
output_path = image_path.replace(".jpg", "_result.jpg")
|
|
||||||
try:
|
|
||||||
result_img.save(output_path, quality=100)
|
|
||||||
print(f"[SUCCESS] 结果已保存至: {output_path}")
|
|
||||||
except Exception as e:
|
|
||||||
print(f"[ERROR] 保存图片失败: {e}")
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
# ================= 配置区域 =================
|
|
||||||
|
|
||||||
# 1. 设置要测试的图片路径
|
|
||||||
# 建议将图片放在与脚本同级目录,或者使用绝对路径
|
|
||||||
TARGET_IMAGE = "/root/phot/None_314_258_0_0041.bmp"
|
|
||||||
|
|
||||||
# TARGET_DIR = "/root/phot_test2" # 修改为你想要读取的目录路径
|
|
||||||
|
|
||||||
# 支持的图片格式
|
|
||||||
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)
|
|
||||||
@@ -1,61 +0,0 @@
|
|||||||
#!/usr/bin/env python3
|
|
||||||
# test_i2c_devices.py
|
|
||||||
|
|
||||||
import os
|
|
||||||
from maix import i2c
|
|
||||||
|
|
||||||
def list_i2c_devices():
|
|
||||||
"""List available I2C device nodes"""
|
|
||||||
print("Available I2C devices:")
|
|
||||||
|
|
||||||
# Check /dev directory
|
|
||||||
try:
|
|
||||||
dev_files = os.listdir("/dev")
|
|
||||||
i2c_devices = [f for f in dev_files if "i2c" in f]
|
|
||||||
if i2c_devices:
|
|
||||||
for dev in sorted(i2c_devices):
|
|
||||||
print(f" /dev/{dev}")
|
|
||||||
else:
|
|
||||||
print(" No /dev/i2c-* devices found!")
|
|
||||||
except Exception as e:
|
|
||||||
print(f" Error listing /dev: {e}")
|
|
||||||
|
|
||||||
def try_i2c_bus(bus_num):
|
|
||||||
"""Try to initialize an I2C bus"""
|
|
||||||
try:
|
|
||||||
bus = i2c.I2C(bus_num, i2c.Mode.MASTER)
|
|
||||||
print(f" I2C bus {bus_num}: OK")
|
|
||||||
return True
|
|
||||||
except RuntimeError as e:
|
|
||||||
print(f" I2C bus {bus_num}: {e}")
|
|
||||||
return False
|
|
||||||
except Exception as e:
|
|
||||||
print(f" I2C bus {bus_num}: Unexpected error: {e}")
|
|
||||||
return False
|
|
||||||
|
|
||||||
def main():
|
|
||||||
print("=" * 60)
|
|
||||||
print("I2C Device Diagnostic")
|
|
||||||
print("=" * 60)
|
|
||||||
|
|
||||||
# List kernel devices
|
|
||||||
list_i2c_devices()
|
|
||||||
|
|
||||||
# Try common bus numbers
|
|
||||||
print("\nTesting I2C buses:")
|
|
||||||
working_buses = []
|
|
||||||
for bus_num in range(10):
|
|
||||||
if try_i2c_bus(bus_num):
|
|
||||||
working_buses.append(bus_num)
|
|
||||||
|
|
||||||
print(f"\nWorking buses: {working_buses}")
|
|
||||||
|
|
||||||
if not working_buses:
|
|
||||||
print("\nERROR: No I2C buses available!")
|
|
||||||
print("Possible causes:")
|
|
||||||
print(" 1. I2C kernel driver not loaded")
|
|
||||||
print(" 2. Device tree doesn't enable I2C")
|
|
||||||
print(" 3. Different kernel version with different device naming")
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
main()
|
|
||||||
@@ -1,246 +0,0 @@
|
|||||||
#!/usr/bin/env python3
|
|
||||||
# -*- coding: utf-8 -*-
|
|
||||||
"""
|
|
||||||
M01激光测距模块测试脚本 - 修正版
|
|
||||||
基于文档中的完整命令示例
|
|
||||||
"""
|
|
||||||
|
|
||||||
from maix import uart, pinmap, time
|
|
||||||
import binascii
|
|
||||||
|
|
||||||
# ==================== 配置 ====================
|
|
||||||
UART_PORT = "/dev/ttyS1"
|
|
||||||
BAUDRATE = 9600
|
|
||||||
|
|
||||||
# 初始化串口
|
|
||||||
try:
|
|
||||||
pinmap.set_pin_function("A18", "UART1_RX")
|
|
||||||
pinmap.set_pin_function("A19", "UART1_TX")
|
|
||||||
laser_uart = uart.UART(UART_PORT, BAUDRATE)
|
|
||||||
print("✅ 硬件初始化完成")
|
|
||||||
except Exception as e:
|
|
||||||
print(f"❌ 初始化失败: {e}")
|
|
||||||
exit(1)
|
|
||||||
|
|
||||||
# ==================== 根据文档的完整命令集 ====================
|
|
||||||
# 1. 激光开关(文档2.3.10,已验证可用)
|
|
||||||
LASER_ON_CMD = bytes([0xAA, 0x00, 0x01, 0xBE, 0x00, 0x01, 0x00, 0x01, 0xC1])
|
|
||||||
LASER_OFF_CMD = bytes([0xAA, 0x00, 0x01, 0xBE, 0x00, 0x01, 0x00, 0x00, 0xC0])
|
|
||||||
|
|
||||||
# 2. 尝试不同的测距命令格式
|
|
||||||
TEST_COMMANDS = [
|
|
||||||
# 格式1:文档2.3.12的单次测量(您测试失败的)
|
|
||||||
{
|
|
||||||
"name": "单次测量 (0x0020)",
|
|
||||||
"cmd": bytes([0xAA, 0x00, 0x00, 0x20, 0x00, 0x01, 0x00, 0x00, 0x21]),
|
|
||||||
"desc": "文档2.3.12 示例命令"
|
|
||||||
},
|
|
||||||
# 格式2:文档2.3.7的读取测量结果
|
|
||||||
{
|
|
||||||
"name": "读取测量结果 (0x0022)",
|
|
||||||
"cmd": bytes([0xAA, 0x80, 0x00, 0x22, 0xA2]),
|
|
||||||
"desc": "文档2.3.7 读取测量结果"
|
|
||||||
},
|
|
||||||
# 格式3:文档2.3.13的快速测量
|
|
||||||
{
|
|
||||||
"name": "快速测量 (0x0022带数据)",
|
|
||||||
"cmd": bytes([0xAA, 0x00, 0x00, 0x22, 0x00, 0x01, 0x00, 0x00, 0x23]),
|
|
||||||
"desc": "文档2.3.13 快速测量"
|
|
||||||
},
|
|
||||||
# 格式4:连续测量模式
|
|
||||||
{
|
|
||||||
"name": "连续测量模式 (0x0021)",
|
|
||||||
"cmd": bytes([0xAA, 0x00, 0x00, 0x21, 0x00, 0x01, 0x00, 0x00, 0x22]),
|
|
||||||
"desc": "文档2.3.14 连续测量"
|
|
||||||
}
|
|
||||||
]
|
|
||||||
|
|
||||||
def clear_buffer():
|
|
||||||
"""清空串口缓冲区"""
|
|
||||||
try:
|
|
||||||
data = laser_uart.read(-1)
|
|
||||||
if data:
|
|
||||||
print(f"清空: {len(data)}字节")
|
|
||||||
except:
|
|
||||||
pass
|
|
||||||
|
|
||||||
def send_and_wait(cmd, name, wait_time=2000):
|
|
||||||
"""发送命令并等待响应"""
|
|
||||||
print(f"\n📤 发送: {name}")
|
|
||||||
print(f" 命令: {cmd.hex()}")
|
|
||||||
|
|
||||||
clear_buffer()
|
|
||||||
|
|
||||||
try:
|
|
||||||
laser_uart.write(cmd)
|
|
||||||
print(f" 已发送 {len(cmd)} 字节")
|
|
||||||
except Exception as e:
|
|
||||||
print(f" ❌ 发送失败: {e}")
|
|
||||||
return None
|
|
||||||
|
|
||||||
# 等待响应
|
|
||||||
start_time = time.ticks_ms()
|
|
||||||
response = b""
|
|
||||||
|
|
||||||
while time.ticks_ms() - start_time < wait_time:
|
|
||||||
try:
|
|
||||||
chunk = laser_uart.read(1)
|
|
||||||
if chunk:
|
|
||||||
response += chunk
|
|
||||||
# 完整响应通常是9或13字节
|
|
||||||
if len(response) >= 9:
|
|
||||||
# 检查是否完整帧
|
|
||||||
if response[0] in [0xAA, 0xEE]:
|
|
||||||
if len(response) >= 13: # 测距完整响应
|
|
||||||
break
|
|
||||||
elif response[0] == 0xEE: # 错误响应
|
|
||||||
break
|
|
||||||
except:
|
|
||||||
break
|
|
||||||
|
|
||||||
time.sleep_ms(10)
|
|
||||||
|
|
||||||
if response:
|
|
||||||
print(f" 📥 响应: {response.hex()}")
|
|
||||||
print(f" 长度: {len(response)} 字节")
|
|
||||||
|
|
||||||
# 解析错误码
|
|
||||||
if response[0] == 0xEE and len(response) >= 9:
|
|
||||||
err_code = (response[7] << 8) | response[8]
|
|
||||||
error_mapping = {
|
|
||||||
0x0000: "无错误",
|
|
||||||
0x0001: "硬件错误",
|
|
||||||
0x0002: "无输出数据",
|
|
||||||
0x0003: "反射信号太弱",
|
|
||||||
0x0004: "反射信号太强",
|
|
||||||
0x0005: "温度太高(>40℃)",
|
|
||||||
0x0006: "温度太低(<-10℃)",
|
|
||||||
0x0007: "电源电压低(<2.5V)",
|
|
||||||
0x0008: "超出量程",
|
|
||||||
0x0009: "读通讯错误",
|
|
||||||
0x000A: "写通讯错误",
|
|
||||||
0x000B: "地址错误"
|
|
||||||
}
|
|
||||||
err_msg = error_mapping.get(err_code, f"未知错误: 0x{err_code:04X}")
|
|
||||||
print(f" ❌ 模块错误: {err_msg}")
|
|
||||||
else:
|
|
||||||
print(" ⚠️ 无响应")
|
|
||||||
|
|
||||||
return response
|
|
||||||
|
|
||||||
def parse_distance_data(response):
|
|
||||||
"""解析距离数据"""
|
|
||||||
if not response or len(response) < 13:
|
|
||||||
return None
|
|
||||||
|
|
||||||
if response[0] != 0xAA or response[3] not in [0x20, 0x21, 0x22]:
|
|
||||||
return None
|
|
||||||
|
|
||||||
# 解析4字节BCD码
|
|
||||||
bcd_bytes = response[6:10]
|
|
||||||
distance_int = 0
|
|
||||||
|
|
||||||
for byte in bcd_bytes:
|
|
||||||
high = (byte >> 4) & 0x0F
|
|
||||||
low = byte & 0x0F
|
|
||||||
|
|
||||||
if high > 9 or low > 9:
|
|
||||||
return None
|
|
||||||
|
|
||||||
distance_int = distance_int * 100 + high * 10 + low
|
|
||||||
|
|
||||||
distance_m = distance_int / 1000.0
|
|
||||||
|
|
||||||
# 信号质量
|
|
||||||
signal = 0
|
|
||||||
if len(response) >= 12:
|
|
||||||
signal = (response[10] << 8) | response[11]
|
|
||||||
|
|
||||||
return {
|
|
||||||
'meters': distance_m,
|
|
||||||
'millimeters': distance_m * 1000,
|
|
||||||
'signal': signal,
|
|
||||||
'raw': response.hex()
|
|
||||||
}
|
|
||||||
|
|
||||||
# ==================== 主测试 ====================
|
|
||||||
print("\n" + "="*50)
|
|
||||||
print("M01激光测距模块详细测试")
|
|
||||||
print("="*50)
|
|
||||||
|
|
||||||
try:
|
|
||||||
# 1. 测试基本连接
|
|
||||||
print("\n1. 测试模块连接...")
|
|
||||||
version_cmd = bytes([0xAA, 0x80, 0x00, 0x0A, 0x8A])
|
|
||||||
resp = send_and_wait(version_cmd, "读取硬件版本")
|
|
||||||
|
|
||||||
if resp and resp[0] == 0xAA and resp[3] == 0x0A:
|
|
||||||
print(f"✅ 模块正常,版本: {resp[6]:02X}{resp[7]:02X}")
|
|
||||||
else:
|
|
||||||
print("❌ 模块连接测试失败")
|
|
||||||
exit(1)
|
|
||||||
|
|
||||||
# 2. 开启激光
|
|
||||||
print("\n2. 开启激光...")
|
|
||||||
resp = send_and_wait(LASER_ON_CMD, "开启激光", 1000)
|
|
||||||
if resp and resp.hex() == "aa0001be00010001c1":
|
|
||||||
print("✅ 激光已开启")
|
|
||||||
|
|
||||||
print(" 等待激光稳定...")
|
|
||||||
time.sleep(2) # 重要等待时间
|
|
||||||
|
|
||||||
# 3. 尝试不同的测距命令
|
|
||||||
print("\n3. 测试不同测距命令...")
|
|
||||||
|
|
||||||
for i, test_cmd in enumerate(TEST_COMMANDS):
|
|
||||||
print(f"\n{'='*30}")
|
|
||||||
print(f"测试 {i+1}: {test_cmd['name']}")
|
|
||||||
print(f"{test_cmd['desc']}")
|
|
||||||
print(f"{'='*30}")
|
|
||||||
|
|
||||||
resp = send_and_wait(test_cmd['cmd'], test_cmd['name'], 3000)
|
|
||||||
|
|
||||||
if resp:
|
|
||||||
if resp[0] == 0xAA and len(resp) >= 13:
|
|
||||||
result = parse_distance_data(resp)
|
|
||||||
if result:
|
|
||||||
print(f"✅ 测距成功!")
|
|
||||||
print(f" 距离: {result['meters']:.3f} m")
|
|
||||||
print(f" 距离: {result['millimeters']:.1f} mm")
|
|
||||||
print(f" 信号质量: {result['signal']}")
|
|
||||||
break
|
|
||||||
else:
|
|
||||||
print("❌ 无法解析距离数据")
|
|
||||||
elif resp[0] == 0xEE:
|
|
||||||
print("❌ 命令执行错误")
|
|
||||||
else:
|
|
||||||
print("❌ 无效响应格式")
|
|
||||||
else:
|
|
||||||
print("❌ 无响应")
|
|
||||||
|
|
||||||
time.sleep(1) # 命令间间隔
|
|
||||||
|
|
||||||
# 4. 关闭激光
|
|
||||||
print("\n4. 关闭激光...")
|
|
||||||
send_and_wait(LASER_OFF_CMD, "关闭激光", 1000)
|
|
||||||
|
|
||||||
print("\n" + "="*50)
|
|
||||||
print("🏁 测试完成")
|
|
||||||
print("="*50)
|
|
||||||
|
|
||||||
print("\n📋 测试总结:")
|
|
||||||
print("1. 模块通信: ✅ 正常")
|
|
||||||
print("2. 激光控制: ✅ 正常")
|
|
||||||
print("3. 测距功能: ❌ 有问题")
|
|
||||||
print("\n建议:")
|
|
||||||
print("1. 检查激光是否实际发光(在暗处观察红点)")
|
|
||||||
print("2. 确保测量目标在有效范围内(0.2-60米)")
|
|
||||||
print("3. 确保目标有足够反射率(白色平面最佳)")
|
|
||||||
print("4. 如果所有测距命令都返回ERR_ADDR,可能是固件版本问题")
|
|
||||||
|
|
||||||
except KeyboardInterrupt:
|
|
||||||
print("\n\n🛑 用户中断")
|
|
||||||
laser_uart.write(LASER_OFF_CMD)
|
|
||||||
print("✅ 已发送关闭指令")
|
|
||||||
except Exception as e:
|
|
||||||
print(f"\n❌ 测试出错: {e}")
|
|
||||||
@@ -1,16 +0,0 @@
|
|||||||
from maix import gpio, pinmap, time
|
|
||||||
|
|
||||||
|
|
||||||
#设置引脚为输出
|
|
||||||
led = gpio.GPIO("A25", gpio.Mode.OUT)
|
|
||||||
#设置低电平
|
|
||||||
led.value(0)
|
|
||||||
|
|
||||||
while 1:
|
|
||||||
# time.sleep_ms(1000)
|
|
||||||
#对该引脚的电平进行取反(原高-》现低)
|
|
||||||
# led.toggle()
|
|
||||||
led.value(1)
|
|
||||||
#延时
|
|
||||||
time.sleep_ms(5000)
|
|
||||||
led.value(0)
|
|
||||||
@@ -1,130 +0,0 @@
|
|||||||
#!/usr/bin/env python3
|
|
||||||
# test_power_with_init.py
|
|
||||||
|
|
||||||
from maix import i2c, time
|
|
||||||
import sys
|
|
||||||
|
|
||||||
# INA226 register addresses
|
|
||||||
INA226_ADDR = 0x40
|
|
||||||
REG_CONFIGURATION = 0x00
|
|
||||||
REG_BUS_VOLTAGE = 0x02
|
|
||||||
REG_CURRENT = 0x04
|
|
||||||
REG_CALIBRATION = 0x05
|
|
||||||
|
|
||||||
# Configuration values
|
|
||||||
CONFIG_VALUE = 0x4527 # Configuration: 16 averages, 1.1ms conversion time, continuous mode
|
|
||||||
CALIBRATION_VALUE = 0x1400 # Calibration value
|
|
||||||
|
|
||||||
def write_register(bus, reg, value):
|
|
||||||
"""Write to INA226 register"""
|
|
||||||
data = [(value >> 8) & 0xFF, value & 0xFF]
|
|
||||||
bus.writeto_mem(INA226_ADDR, reg, bytes(data))
|
|
||||||
|
|
||||||
def read_register(bus, reg):
|
|
||||||
"""Read from INA226 register"""
|
|
||||||
data = bus.readfrom_mem(INA226_ADDR, reg, 2)
|
|
||||||
return (data[0] << 8) | data[1]
|
|
||||||
|
|
||||||
def init_ina226(bus):
|
|
||||||
"""Initialize INA226 chip"""
|
|
||||||
try:
|
|
||||||
# Write configuration register
|
|
||||||
write_register(bus, REG_CONFIGURATION, CONFIG_VALUE)
|
|
||||||
time.sleep_ms(10)
|
|
||||||
|
|
||||||
# Write calibration register
|
|
||||||
write_register(bus, REG_CALIBRATION, CALIBRATION_VALUE)
|
|
||||||
time.sleep_ms(10)
|
|
||||||
|
|
||||||
# Verify configuration by reading it back
|
|
||||||
config_read = read_register(bus, REG_CONFIGURATION)
|
|
||||||
if config_read != CONFIG_VALUE:
|
|
||||||
print(f" Warning: Config readback mismatch: 0x{config_read:04X} != 0x{CONFIG_VALUE:04X}")
|
|
||||||
|
|
||||||
return True
|
|
||||||
except Exception as e:
|
|
||||||
print(f" Init failed: {e}")
|
|
||||||
return False
|
|
||||||
|
|
||||||
def read_voltage(bus):
|
|
||||||
"""Read bus voltage"""
|
|
||||||
raw = read_register(bus, REG_BUS_VOLTAGE)
|
|
||||||
voltage = raw * 1.25 / 1000
|
|
||||||
return voltage
|
|
||||||
|
|
||||||
def read_current(bus):
|
|
||||||
"""Read current"""
|
|
||||||
raw = read_register(bus, REG_CURRENT)
|
|
||||||
# Handle signed value
|
|
||||||
if raw & 0x8000:
|
|
||||||
raw = raw - 0x10000
|
|
||||||
current_lsb = 0.001 * CALIBRATION_VALUE / 4096
|
|
||||||
current = raw * current_lsb * 1000 # mA
|
|
||||||
return current
|
|
||||||
|
|
||||||
def test_i2c_bus(bus_num):
|
|
||||||
"""Test a single I2C bus with full initialization"""
|
|
||||||
print(f"\n{'='*60}")
|
|
||||||
print(f"Testing I2C Bus {bus_num}")
|
|
||||||
print(f"{'='*60}")
|
|
||||||
|
|
||||||
try:
|
|
||||||
# Step 1: Initialize I2C bus
|
|
||||||
print(f" 1. Initializing I2C bus...")
|
|
||||||
bus = i2c.I2C(bus_num, i2c.Mode.MASTER)
|
|
||||||
print(f" OK")
|
|
||||||
|
|
||||||
# Step 2: Initialize INA226
|
|
||||||
print(f" 2. Initializing INA226...")
|
|
||||||
if not init_ina226(bus):
|
|
||||||
print(f" FAILED")
|
|
||||||
return False
|
|
||||||
print(f" OK")
|
|
||||||
|
|
||||||
# Step 3: Read voltage multiple times
|
|
||||||
print(f" 3. Reading voltage...")
|
|
||||||
for i in range(5):
|
|
||||||
try:
|
|
||||||
voltage = read_voltage(bus)
|
|
||||||
current = read_current(bus)
|
|
||||||
print(f" Read {i+1}: {voltage:.3f}V, {current:.1f}mA")
|
|
||||||
time.sleep_ms(100)
|
|
||||||
except Exception as e:
|
|
||||||
print(f" Read {i+1} failed: {e}")
|
|
||||||
|
|
||||||
print(f" SUCCESS")
|
|
||||||
return True
|
|
||||||
|
|
||||||
except Exception as e:
|
|
||||||
print(f" FAILED: {e}")
|
|
||||||
import traceback
|
|
||||||
traceback.print_exc()
|
|
||||||
return False
|
|
||||||
|
|
||||||
def main():
|
|
||||||
"""Test all I2C buses"""
|
|
||||||
print("INA226 Test with Proper Initialization")
|
|
||||||
print("=" * 60)
|
|
||||||
|
|
||||||
# Test buses in order of likelihood
|
|
||||||
test_order = [5, 1, 3, 4, 0, 2]
|
|
||||||
|
|
||||||
success_buses = []
|
|
||||||
|
|
||||||
for bus_num in test_order:
|
|
||||||
if test_i2c_bus(bus_num):
|
|
||||||
success_buses.append(bus_num)
|
|
||||||
# If we found a working bus, stop testing others
|
|
||||||
break
|
|
||||||
|
|
||||||
print(f"\n{'='*60}")
|
|
||||||
print(f"Summary:")
|
|
||||||
print(f" Working buses: {success_buses}")
|
|
||||||
if not success_buses:
|
|
||||||
print(f" ERROR: No working I2C bus found!")
|
|
||||||
return 1
|
|
||||||
|
|
||||||
return 0
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
sys.exit(main())
|
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -1,343 +0,0 @@
|
|||||||
#!/usr/bin/env python3
|
|
||||||
# -*- coding: utf-8 -*-
|
|
||||||
"""
|
|
||||||
Stage2 黑三角 YOLO —— 在 Maix 设备上用本地图片测试(与线上 target_roi_yolo.try_black_triangle_boxes_work 完全一致)。
|
|
||||||
|
|
||||||
不在 PC 上跑 NPU;需把脚本与 config / target_roi_yolo.py 同步到设备,并在设备上执行。
|
|
||||||
|
|
||||||
典型用法
|
|
||||||
--------
|
|
||||||
# 输入已是 Stage1 裁切(与你保存的 stage2_roi_*.jpg 一致)
|
|
||||||
python test/test_stage2_black_yolo_device.py /root/phot/stage2_roi_xxx.jpg
|
|
||||||
|
|
||||||
# 输入为整幅相机图,手动给出 Stage1 环靶 ROI(与线上日志 ring全图=[rx0,ry0,rx1,ry1] 一致)
|
|
||||||
python test/test_stage2_black_yolo_device.py /root/phot/full.jpg --roi 197,196,507,461
|
|
||||||
|
|
||||||
# 对比 native / letterbox 坐标映射(排查 contain 训练与推理对齐)
|
|
||||||
python test/test_stage2_black_yolo_device.py ./crop.jpg --compare-coord
|
|
||||||
|
|
||||||
# 覆盖置信度、模型路径(仍读其余项自 config)
|
|
||||||
python test/test_stage2_black_yolo_device.py ./crop.jpg --conf 0.25 -m /maixapp/apps/t11/model_270648.mud
|
|
||||||
|
|
||||||
# 只看 NPU 原始框(映射前):判断坐标是 ~224 网络空间还是归一化 0~1
|
|
||||||
python test/test_stage2_black_yolo_device.py ./crop.jpg --conf 0.05 --dump-raw 15
|
|
||||||
|
|
||||||
依赖:MaixPy(maix.nn)、OpenCV(cv2)、numpy;项目根须在 sys.path(本脚本已插入上级目录)。
|
|
||||||
"""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import argparse
|
|
||||||
import os
|
|
||||||
import sys
|
|
||||||
|
|
||||||
_ROOT = os.path.abspath(os.path.join(os.path.dirname(__file__), ".."))
|
|
||||||
if _ROOT not in sys.path:
|
|
||||||
sys.path.insert(0, _ROOT)
|
|
||||||
|
|
||||||
|
|
||||||
def _parse_roi(s: str) -> tuple[int, int, int, int]:
|
|
||||||
parts = [p.strip() for p in s.replace(" ", "").split(",")]
|
|
||||||
if len(parts) != 4:
|
|
||||||
raise ValueError("ROI 需要 4 个整数:x0,y0,x1,y1")
|
|
||||||
return tuple(int(x) for x in parts) # type: ignore[return-value]
|
|
||||||
|
|
||||||
|
|
||||||
def _load_rgb_numpy(path: str) -> "object":
|
|
||||||
import cv2
|
|
||||||
import numpy as np
|
|
||||||
|
|
||||||
bgr = cv2.imread(path, cv2.IMREAD_COLOR)
|
|
||||||
if bgr is None:
|
|
||||||
raise FileNotFoundError(f"cv2.imread 失败: {path}")
|
|
||||||
rgb = cv2.cvtColor(bgr, cv2.COLOR_BGR2RGB)
|
|
||||||
return np.ascontiguousarray(rgb, dtype=np.uint8)
|
|
||||||
|
|
||||||
|
|
||||||
def _draw_boxes_on_crop(
|
|
||||||
slab_rgb,
|
|
||||||
boxes: list[tuple[int, int, int, int]],
|
|
||||||
labels: list[str] | None = None,
|
|
||||||
):
|
|
||||||
"""slab_rgb: H×W×3 RGB uint8;boxes 为扩 margin 后的 Stage2 子框(与线上绿框一致)。"""
|
|
||||||
import cv2
|
|
||||||
|
|
||||||
vis = slab_rgb.copy()
|
|
||||||
bgr = cv2.cvtColor(vis, cv2.COLOR_RGB2BGR)
|
|
||||||
rh, rw = bgr.shape[:2]
|
|
||||||
for i, (bx0, by0, bx1, by1) in enumerate(boxes):
|
|
||||||
x0, y0 = int(bx0), int(by0)
|
|
||||||
x1, y1 = int(bx1) - 1, int(by1) - 1
|
|
||||||
x1 = max(x0, min(x1, rw - 1))
|
|
||||||
y1 = max(y0, min(y1, rh - 1))
|
|
||||||
cv2.rectangle(bgr, (x0, y0), (x1, y1), (0, 255, 0), 2)
|
|
||||||
tag = labels[i] if labels and i < len(labels) else f"s2_{i}"
|
|
||||||
cv2.putText(
|
|
||||||
bgr,
|
|
||||||
tag,
|
|
||||||
(x0, max(0, y0 - 4)),
|
|
||||||
cv2.FONT_HERSHEY_SIMPLEX,
|
|
||||||
0.5,
|
|
||||||
(0, 255, 0),
|
|
||||||
1,
|
|
||||||
cv2.LINE_AA,
|
|
||||||
)
|
|
||||||
return bgr
|
|
||||||
|
|
||||||
|
|
||||||
class _PrintLogger:
|
|
||||||
def info(self, msg):
|
|
||||||
print(msg)
|
|
||||||
|
|
||||||
def warning(self, msg):
|
|
||||||
print(msg)
|
|
||||||
|
|
||||||
def error(self, msg):
|
|
||||||
print(msg)
|
|
||||||
|
|
||||||
|
|
||||||
def _run_once(yroi_mod, img_rgb, roi_xyxy, logger):
|
|
||||||
boxes = yroi_mod.try_black_triangle_boxes_work(img_rgb, roi_xyxy, logger)
|
|
||||||
rx0, ry0, rx1, ry1 = roi_xyxy
|
|
||||||
slab = img_rgb[ry0:ry1, rx0:rx1].copy()
|
|
||||||
return boxes, slab
|
|
||||||
|
|
||||||
|
|
||||||
def _copy_dump_raw_rows(yroi_mod, objs):
|
|
||||||
"""把 Maix detect 返回对象拷贝成基础类型,避免 native 对象跨下一次 detect 存活。"""
|
|
||||||
rows = []
|
|
||||||
for o in objs:
|
|
||||||
cid = yroi_mod._det_obj_class_id(o)
|
|
||||||
try:
|
|
||||||
sc = float(getattr(o, "score", 0.0))
|
|
||||||
except (TypeError, ValueError):
|
|
||||||
sc = 0.0
|
|
||||||
rows.append((cid, sc, float(o.x), float(o.y), float(o.w), float(o.h)))
|
|
||||||
return rows
|
|
||||||
|
|
||||||
|
|
||||||
def _dump_raw_and_hard_exit(det, yroi_mod, slab_for_det, rw_s, rh_s, net_w, net_h, conf_th, iou_th, limit):
|
|
||||||
"""
|
|
||||||
MaixPy 某些版本在 YOLO detect 返回对象正常析构时会 SIGSEGV/pure virtual。
|
|
||||||
raw dump 是诊断路径,打印完成后硬退出,绕过 Python/native 析构链。
|
|
||||||
"""
|
|
||||||
from maix import image as maix_image
|
|
||||||
|
|
||||||
roi_maix = maix_image.cv2image(slab_for_det, False, False)
|
|
||||||
raw = det.detect(roi_maix, conf_th=conf_th, iou_th=iou_th)
|
|
||||||
objs = yroi_mod._normalize_objs(raw if raw is not None else [])
|
|
||||||
dump_rows = _copy_dump_raw_rows(yroi_mod, objs)
|
|
||||||
raw_count = len(dump_rows)
|
|
||||||
print(
|
|
||||||
f"[DUMP-RAW] slab={rw_s}×{rh_s} net={net_w}×{net_h} "
|
|
||||||
f"conf={conf_th} iou={iou_th} → NMS 后 raw 框数={raw_count}(与 coord_mode 无关)"
|
|
||||||
)
|
|
||||||
npr = min(int(limit), raw_count)
|
|
||||||
for i in range(npr):
|
|
||||||
cid, sc, x, y, ww, hh = dump_rows[i]
|
|
||||||
print(f" #{i} cls={cid} score={sc:.4f} xywh=({x:.3f},{y:.3f},{ww:.3f},{hh:.3f})")
|
|
||||||
if dump_rows:
|
|
||||||
xs = [r[2] for r in dump_rows]
|
|
||||||
ws = [r[4] for r in dump_rows]
|
|
||||||
print(
|
|
||||||
f"[DUMP-RAW] hint: x 范围≈[{min(xs):.2f},{max(xs):.2f}] "
|
|
||||||
f"w 范围≈[{min(ws):.2f},{max(ws):.2f}] — "
|
|
||||||
f"若整体在 0~{net_w} 量级多为网络画布坐标→应用 letterbox;"
|
|
||||||
f"若 x,w 多在 0~1→可能是归一化,需在代码里乘 net 尺寸"
|
|
||||||
)
|
|
||||||
print("[INFO] --dump-raw 已完成;为规避 MaixPy YOLO native 析构崩溃,测试进程将直接退出。")
|
|
||||||
sys.stdout.flush()
|
|
||||||
sys.stderr.flush()
|
|
||||||
os._exit(0)
|
|
||||||
|
|
||||||
|
|
||||||
def main():
|
|
||||||
ap = argparse.ArgumentParser(
|
|
||||||
description="Stage2 黑三角 YOLO 设备本地图测试",
|
|
||||||
formatter_class=argparse.RawDescriptionHelpFormatter,
|
|
||||||
epilog=__doc__,
|
|
||||||
)
|
|
||||||
ap.add_argument("image", help="本地图片路径(设备上的路径)")
|
|
||||||
ap.add_argument(
|
|
||||||
"--roi",
|
|
||||||
default="",
|
|
||||||
metavar="x0,y0,x1,y1",
|
|
||||||
help="可选。若填写:image 为整幅图,在此图上取 Stage1 ROI 再跑 Stage2;"
|
|
||||||
"留空:image 本身就是 Stage1 裁切图(默认)",
|
|
||||||
)
|
|
||||||
ap.add_argument("-o", "--output", default="", help="输出可视化路径;默认 原名_stage2_vis.jpg")
|
|
||||||
ap.add_argument("-m", "--model", default="", help="覆盖 config.TRIANGLE_BLACK_YOLO_MODEL_PATH")
|
|
||||||
ap.add_argument("--conf", type=float, default=None, help="覆盖 TRIANGLE_BLACK_YOLO_CONF_TH")
|
|
||||||
ap.add_argument("--iou", type=float, default=None, help="覆盖 TRIANGLE_BLACK_YOLO_IOU_TH")
|
|
||||||
ap.add_argument(
|
|
||||||
"--coord",
|
|
||||||
choices=["native", "letterbox"],
|
|
||||||
default="",
|
|
||||||
help="覆盖 TRIANGLE_BLACK_YOLO_COORD_MODE;默认用 config",
|
|
||||||
)
|
|
||||||
ap.add_argument(
|
|
||||||
"--compare-coord",
|
|
||||||
action="store_true",
|
|
||||||
help="各跑一次 native 与 letterbox,输出两张图 *_stage2_native.jpg / *_stage2_letterbox.jpg",
|
|
||||||
)
|
|
||||||
ap.add_argument(
|
|
||||||
"--fresh-detector",
|
|
||||||
action="store_true",
|
|
||||||
help="清掉 YOLO 缓存再测(换模型或排查缓存时用)",
|
|
||||||
)
|
|
||||||
ap.add_argument(
|
|
||||||
"--allow-save-roi",
|
|
||||||
action="store_true",
|
|
||||||
help="不强制关闭 TRIANGLE_BLACK_YOLO_SAVE_ROI_CROP(默认测试时会关掉以免写满相册目录)",
|
|
||||||
)
|
|
||||||
ap.add_argument(
|
|
||||||
"--dump-raw",
|
|
||||||
type=int,
|
|
||||||
default=0,
|
|
||||||
metavar="N",
|
|
||||||
help="打印前 N 个 detect 原始框 x,y,w,h,score,cls(coord 映射前;native/letterbox 共用同一批 raw)",
|
|
||||||
)
|
|
||||||
args = ap.parse_args()
|
|
||||||
|
|
||||||
img_path = os.path.abspath(args.image)
|
|
||||||
if not os.path.isfile(img_path):
|
|
||||||
print(f"[ERR] 找不到图片: {img_path}")
|
|
||||||
sys.exit(1)
|
|
||||||
|
|
||||||
try:
|
|
||||||
import config as cfg
|
|
||||||
import target_roi_yolo as yroi
|
|
||||||
except ImportError as e:
|
|
||||||
print(f"[ERR] 无法导入 config / target_roi_yolo: {e}")
|
|
||||||
sys.exit(1)
|
|
||||||
|
|
||||||
if args.fresh_detector:
|
|
||||||
yroi.reset_yolo_detector_cache()
|
|
||||||
|
|
||||||
# 备份并临时覆盖 config(单进程顺序跑)
|
|
||||||
bak: dict[str, object] = {}
|
|
||||||
|
|
||||||
def _patch(key: str, val: object):
|
|
||||||
if key not in bak:
|
|
||||||
bak[key] = getattr(cfg, key, None)
|
|
||||||
setattr(cfg, key, val)
|
|
||||||
|
|
||||||
def _restore():
|
|
||||||
for k, v in bak.items():
|
|
||||||
setattr(cfg, k, v)
|
|
||||||
|
|
||||||
try:
|
|
||||||
_patch("TRIANGLE_BLACK_YOLO_ENABLE", True)
|
|
||||||
if not args.allow_save_roi:
|
|
||||||
_patch("TRIANGLE_BLACK_YOLO_SAVE_ROI_CROP", False)
|
|
||||||
if args.model.strip():
|
|
||||||
_patch("TRIANGLE_BLACK_YOLO_MODEL_PATH", args.model.strip())
|
|
||||||
if args.conf is not None:
|
|
||||||
_patch("TRIANGLE_BLACK_YOLO_CONF_TH", float(args.conf))
|
|
||||||
if args.iou is not None:
|
|
||||||
_patch("TRIANGLE_BLACK_YOLO_IOU_TH", float(args.iou))
|
|
||||||
if args.coord and not args.compare_coord:
|
|
||||||
_patch("TRIANGLE_BLACK_YOLO_COORD_MODE", args.coord)
|
|
||||||
|
|
||||||
mp = getattr(cfg, "TRIANGLE_BLACK_YOLO_MODEL_PATH", "") or ""
|
|
||||||
if not os.path.isfile(mp):
|
|
||||||
print(f"[ERR] 模型文件不存在: {mp}")
|
|
||||||
sys.exit(1)
|
|
||||||
|
|
||||||
img_rgb = _load_rgb_numpy(img_path)
|
|
||||||
h, w = int(img_rgb.shape[0]), int(img_rgb.shape[1])
|
|
||||||
|
|
||||||
if args.roi.strip():
|
|
||||||
roi_xyxy = _parse_roi(args.roi.strip())
|
|
||||||
rx0, ry0, rx1, ry1 = [int(round(float(v))) for v in roi_xyxy]
|
|
||||||
if rx1 <= rx0 or ry1 <= ry0:
|
|
||||||
print("[ERR] ROI 无效:需满足 x1>x0 且 y1>y0")
|
|
||||||
sys.exit(1)
|
|
||||||
# 与 target_roi_yolo.try_black_triangle_boxes_work 相同的 clip
|
|
||||||
rx0 = max(0, min(rx0, w - 1))
|
|
||||||
ry0 = max(0, min(ry0, h - 1))
|
|
||||||
rx1 = max(rx0 + 1, min(rx1, w))
|
|
||||||
ry1 = max(ry0 + 1, min(ry1, h))
|
|
||||||
ring_roi = (rx0, ry0, rx1, ry1)
|
|
||||||
print(f"[INFO] 模式=整图+ROI ring={ring_roi} image={w}×{h}")
|
|
||||||
else:
|
|
||||||
ring_roi = (0, 0, w, h)
|
|
||||||
print(f"[INFO] 模式=已是 Stage1 裁切 crop={w}×{h}")
|
|
||||||
|
|
||||||
logger = _PrintLogger()
|
|
||||||
det = yroi._get_detector(mp)
|
|
||||||
if det is None:
|
|
||||||
print("[ERR] 无法加载 nn.YOLOv5(检查模型路径与 Maix 环境)")
|
|
||||||
sys.exit(1)
|
|
||||||
net_w = int(det.input_width())
|
|
||||||
net_h = int(det.input_height())
|
|
||||||
print(f"[INFO] model={mp} net_in={net_w}×{net_h}")
|
|
||||||
|
|
||||||
rx0, ry0, rx1, ry1 = ring_roi
|
|
||||||
import numpy as np
|
|
||||||
|
|
||||||
slab_for_det = np.ascontiguousarray(img_rgb[ry0:ry1, rx0:rx1], dtype=np.uint8).copy()
|
|
||||||
rh_s, rw_s = int(slab_for_det.shape[0]), int(slab_for_det.shape[1])
|
|
||||||
|
|
||||||
modes = ["native", "letterbox"] if args.compare_coord else [
|
|
||||||
(args.coord or getattr(cfg, "TRIANGLE_BLACK_YOLO_COORD_MODE", "native"))
|
|
||||||
]
|
|
||||||
|
|
||||||
base, ext = os.path.splitext(img_path)
|
|
||||||
ext = ext if ext else ".jpg"
|
|
||||||
|
|
||||||
for mode in modes:
|
|
||||||
_patch("TRIANGLE_BLACK_YOLO_COORD_MODE", mode)
|
|
||||||
cur_coord = getattr(cfg, "TRIANGLE_BLACK_YOLO_COORD_MODE", mode)
|
|
||||||
print(f"[INFO] --- TRIANGLE_BLACK_YOLO_COORD_MODE={cur_coord} ---")
|
|
||||||
|
|
||||||
boxes, slab = _run_once(yroi, img_rgb, ring_roi, logger)
|
|
||||||
print(
|
|
||||||
f"[INFO] 子框数量={len(boxes)} conf={getattr(cfg, 'TRIANGLE_BLACK_YOLO_CONF_TH', '?')} "
|
|
||||||
f"coord={cur_coord}"
|
|
||||||
)
|
|
||||||
for i, b in enumerate(boxes):
|
|
||||||
print(f" s2_{i}: {b}")
|
|
||||||
|
|
||||||
if args.compare_coord:
|
|
||||||
out_path = f"{base}_stage2_{mode}{ext}"
|
|
||||||
elif args.output.strip():
|
|
||||||
out_path = args.output.strip()
|
|
||||||
else:
|
|
||||||
out_path = base + "_stage2_vis" + ext
|
|
||||||
|
|
||||||
import cv2
|
|
||||||
|
|
||||||
bgr = _draw_boxes_on_crop(slab, boxes)
|
|
||||||
cv2.imwrite(out_path, bgr, [int(cv2.IMWRITE_JPEG_QUALITY), 92])
|
|
||||||
print(f"[OK] saved: {out_path}")
|
|
||||||
|
|
||||||
if args.compare_coord:
|
|
||||||
print(
|
|
||||||
"[HINT] contain 训练时若 letterbox 对齐更好,请将 config 里 "
|
|
||||||
"TRIANGLE_BLACK_YOLO_COORD_MODE 设为 letterbox"
|
|
||||||
)
|
|
||||||
|
|
||||||
if args.dump_raw > 0:
|
|
||||||
conf_th = float(getattr(cfg, "TRIANGLE_BLACK_YOLO_CONF_TH", 0.5))
|
|
||||||
iou_th = float(getattr(cfg, "TRIANGLE_BLACK_YOLO_IOU_TH", 0.45))
|
|
||||||
print("\n[INFO] --dump-raw 放在最后执行,避免 raw native 对象影响 compare-coord 流程。")
|
|
||||||
_dump_raw_and_hard_exit(
|
|
||||||
det,
|
|
||||||
yroi,
|
|
||||||
slab_for_det,
|
|
||||||
rw_s,
|
|
||||||
rh_s,
|
|
||||||
net_w,
|
|
||||||
net_h,
|
|
||||||
conf_th,
|
|
||||||
iou_th,
|
|
||||||
args.dump_raw,
|
|
||||||
)
|
|
||||||
|
|
||||||
finally:
|
|
||||||
_restore()
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
main()
|
|
||||||
@@ -1,242 +0,0 @@
|
|||||||
#!/usr/bin/env python3
|
|
||||||
# -*- coding: utf-8 -*-
|
|
||||||
"""
|
|
||||||
单张图片快速测试:三角形四角标记识别 + 单应性落点 + PnP 估距
|
|
||||||
|
|
||||||
用法(在板子上):
|
|
||||||
python3 test/test_triangle_one_image.py --image /root/phot/xxx.jpg --out /root/phot/tri_out.jpg
|
|
||||||
|
|
||||||
调参对比(不改代码,临时覆盖 config.TRIANGLE_*):
|
|
||||||
python3 test/test_triangle_one_image.py --image /root/phot/xxx.jpg --preset shadow
|
|
||||||
python3 test/test_triangle_one_image.py --image /root/phot/xxx.jpg --max-interior-gray 160 --min-dark-ratio 0.20
|
|
||||||
"""
|
|
||||||
|
|
||||||
import argparse
|
|
||||||
import json
|
|
||||||
import os
|
|
||||||
import time
|
|
||||||
from typing import Any, Dict, Tuple
|
|
||||||
|
|
||||||
import cv2
|
|
||||||
import numpy as np
|
|
||||||
|
|
||||||
import config
|
|
||||||
import triangle_target as tri_mod
|
|
||||||
from triangle_target import (
|
|
||||||
detect_triangle_markers,
|
|
||||||
load_camera_from_xml,
|
|
||||||
load_triangle_positions,
|
|
||||||
try_triangle_scoring,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def _apply_overrides(args) -> None:
|
|
||||||
# 预设:阴影/低对比度场景更宽松(尽量保持速度:不启 CLAHE)
|
|
||||||
if args.preset == "shadow":
|
|
||||||
setattr(config, "TRIANGLE_ENABLE_CLAHE_FALLBACK", False)
|
|
||||||
setattr(config, "TRIANGLE_MIN_CONTRAST_DIFF", 0)
|
|
||||||
setattr(config, "TRIANGLE_MAX_INTERIOR_GRAY", 160)
|
|
||||||
setattr(config, "TRIANGLE_DARK_PIXEL_GRAY", 160)
|
|
||||||
setattr(config, "TRIANGLE_MIN_DARK_RATIO", 0.20)
|
|
||||||
# adaptive 只在 Otsu 失败时尝试,保持尝试次数很少
|
|
||||||
setattr(config, "TRIANGLE_ADAPTIVE_BLOCK_SIZES", (21,))
|
|
||||||
|
|
||||||
# 手动覆盖(优先级高于 preset)
|
|
||||||
if args.max_interior_gray is not None:
|
|
||||||
setattr(config, "TRIANGLE_MAX_INTERIOR_GRAY", int(args.max_interior_gray))
|
|
||||||
if args.dark_pixel_gray is not None:
|
|
||||||
setattr(config, "TRIANGLE_DARK_PIXEL_GRAY", int(args.dark_pixel_gray))
|
|
||||||
if args.min_dark_ratio is not None:
|
|
||||||
setattr(config, "TRIANGLE_MIN_DARK_RATIO", float(args.min_dark_ratio))
|
|
||||||
if args.min_contrast_diff is not None:
|
|
||||||
setattr(config, "TRIANGLE_MIN_CONTRAST_DIFF", int(args.min_contrast_diff))
|
|
||||||
if args.detect_scale is not None:
|
|
||||||
setattr(config, "TRIANGLE_DETECT_SCALE", float(args.detect_scale))
|
|
||||||
if args.adaptive_blocks is not None:
|
|
||||||
bs = tuple(int(x) for x in args.adaptive_blocks.split(",") if x.strip())
|
|
||||||
setattr(config, "TRIANGLE_ADAPTIVE_BLOCK_SIZES", bs)
|
|
||||||
|
|
||||||
|
|
||||||
def _dump_config() -> Dict[str, Any]:
|
|
||||||
keys = [
|
|
||||||
"TRIANGLE_DETECT_SCALE",
|
|
||||||
"TRIANGLE_SIZE_RANGE",
|
|
||||||
"TRIANGLE_MAX_INTERIOR_GRAY",
|
|
||||||
"TRIANGLE_DARK_PIXEL_GRAY",
|
|
||||||
"TRIANGLE_MIN_DARK_RATIO",
|
|
||||||
"TRIANGLE_MIN_CONTRAST_DIFF",
|
|
||||||
"TRIANGLE_ADAPTIVE_BLOCK_SIZES",
|
|
||||||
"TRIANGLE_MAX_FILTERED_FOR_COMBO",
|
|
||||||
"TRIANGLE_EARLY_EXIT_CANDIDATES",
|
|
||||||
"TRIANGLE_ENABLE_CLAHE_FALLBACK",
|
|
||||||
]
|
|
||||||
out = {}
|
|
||||||
for k in keys:
|
|
||||||
out[k] = getattr(config, k, None)
|
|
||||||
return out
|
|
||||||
|
|
||||||
|
|
||||||
def _draw_tri_debug(img_bgr: np.ndarray, tri: Dict[str, Any]) -> np.ndarray:
|
|
||||||
out = img_bgr.copy()
|
|
||||||
markers = tri.get("markers") or []
|
|
||||||
|
|
||||||
# 画三角形轮廓 + center + id
|
|
||||||
for m in markers:
|
|
||||||
corners = np.array(m.get("corners", []), dtype=np.int32)
|
|
||||||
if corners.size == 0:
|
|
||||||
continue
|
|
||||||
cv2.polylines(out, [corners], True, (0, 255, 0), 2)
|
|
||||||
c = m.get("center") or (corners[:, 0].mean(), corners[:, 1].mean())
|
|
||||||
cx, cy = int(c[0]), int(c[1])
|
|
||||||
cv2.circle(out, (cx, cy), 4, (0, 0, 255), -1)
|
|
||||||
mid = m.get("id", "?")
|
|
||||||
cv2.putText(out, f"T{mid}", (cx - 18, cy - 10), cv2.FONT_HERSHEY_SIMPLEX, 0.55, (0, 255, 0), 1)
|
|
||||||
|
|
||||||
# 若有 homography,画靶心(把 (0,0) 反投影到图像)
|
|
||||||
H = tri.get("homography")
|
|
||||||
if H is not None:
|
|
||||||
try:
|
|
||||||
H = np.array(H, dtype=np.float64)
|
|
||||||
H_inv = np.linalg.inv(H)
|
|
||||||
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(out, (ocx, ocy), 5, (0, 0, 255), -1)
|
|
||||||
cv2.circle(out, (ocx, ocy), 10, (0, 0, 255), 1)
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
|
|
||||||
# 叠加结果信息
|
|
||||||
lines = []
|
|
||||||
if tri.get("ok"):
|
|
||||||
lines.append("tri_ok=True")
|
|
||||||
if tri.get("dx_cm") is not None and tri.get("dy_cm") is not None:
|
|
||||||
lines.append(f"dx,dy=({tri['dx_cm']:.2f},{tri['dy_cm']:.2f})cm")
|
|
||||||
if tri.get("distance_m") is not None:
|
|
||||||
lines.append(f"dist={float(tri['distance_m']):.2f}m")
|
|
||||||
else:
|
|
||||||
lines.append("tri_ok=False")
|
|
||||||
|
|
||||||
y0 = 22
|
|
||||||
for i, t in enumerate(lines):
|
|
||||||
cv2.putText(out, t, (10, y0 + i * 18), cv2.FONT_HERSHEY_SIMPLEX, 0.55, (0, 255, 0), 1)
|
|
||||||
return out
|
|
||||||
|
|
||||||
|
|
||||||
def main():
|
|
||||||
ap = argparse.ArgumentParser()
|
|
||||||
ap.add_argument("--image", required=True, help="输入图片路径(jpg/png)")
|
|
||||||
ap.add_argument("--out", default="", help="输出标注图片路径(可选)")
|
|
||||||
ap.add_argument("--laser-x", type=int, default=-1, help="激光点 x(像素),默认用图像中心")
|
|
||||||
ap.add_argument("--laser-y", type=int, default=-1, help="激光点 y(像素),默认用图像中心")
|
|
||||||
ap.add_argument("--preset", choices=["", "shadow"], default="", help="调参预设(shadow=阴影更鲁棒,不启 CLAHE)")
|
|
||||||
ap.add_argument("--max-interior-gray", type=int, default=None)
|
|
||||||
ap.add_argument("--dark-pixel-gray", type=int, default=None)
|
|
||||||
ap.add_argument("--min-dark-ratio", type=float, default=None)
|
|
||||||
ap.add_argument("--min-contrast-diff", type=int, default=None)
|
|
||||||
ap.add_argument("--detect-scale", type=float, default=None)
|
|
||||||
ap.add_argument("--adaptive-blocks", default=None, help="例如: 11,21 ;为空表示不改")
|
|
||||||
ap.add_argument("--verbose", action="store_true", help="输出更多检测阶段信息")
|
|
||||||
args = ap.parse_args()
|
|
||||||
|
|
||||||
_apply_overrides(args)
|
|
||||||
# triangle_target.py 的日志默认写到 logger_manager;在离线脚本里 logger 可能未初始化。
|
|
||||||
# verbose 模式下把 _log 重定向为 print,方便直接看到诊断信息。
|
|
||||||
if args.verbose:
|
|
||||||
try:
|
|
||||||
tri_mod._log = lambda msg: print(str(msg))
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
|
|
||||||
img_bgr = cv2.imread(args.image, cv2.IMREAD_COLOR)
|
|
||||||
if img_bgr is None:
|
|
||||||
raise SystemExit(f"读图失败:{args.image}")
|
|
||||||
# triangle_target.try_triangle_scoring 约定输入为 RGB;OpenCV imread 返回 BGR
|
|
||||||
img_rgb = cv2.cvtColor(img_bgr, cv2.COLOR_BGR2RGB)
|
|
||||||
|
|
||||||
h, w = img_bgr.shape[:2]
|
|
||||||
if args.laser_x >= 0 and args.laser_y >= 0:
|
|
||||||
laser_point = (int(args.laser_x), int(args.laser_y))
|
|
||||||
else:
|
|
||||||
laser_point = (w // 2, h // 2)
|
|
||||||
|
|
||||||
K, dist = load_camera_from_xml(getattr(config, "CAMERA_CALIB_XML", ""))
|
|
||||||
pos = load_triangle_positions(getattr(config, "TRIANGLE_POSITIONS_JSON", ""))
|
|
||||||
|
|
||||||
print("[tri-test] image:", args.image, "shape:", (h, w))
|
|
||||||
print("[tri-test] laser_point:", laser_point)
|
|
||||||
print("[tri-test] calib_ok:", bool(K is not None and dist is not None), "pos_ok:", bool(pos))
|
|
||||||
print("[tri-test] config:", json.dumps(_dump_config(), ensure_ascii=False))
|
|
||||||
|
|
||||||
# 先单独跑一次三角形候选检测,便于区分“没找到候选” vs “找到候选但评分/单应性失败”
|
|
||||||
scale = float(getattr(config, "TRIANGLE_DETECT_SCALE", 0.5) or 0.5)
|
|
||||||
if not (0.05 <= scale <= 1.0):
|
|
||||||
scale = 0.5
|
|
||||||
long_side = max(h, w)
|
|
||||||
max_dim = max(64, int(long_side * scale))
|
|
||||||
if long_side > max_dim:
|
|
||||||
det_scale = max_dim / long_side
|
|
||||||
det_w = int(w * det_scale)
|
|
||||||
det_h = int(h * det_scale)
|
|
||||||
img_det = cv2.resize(img_bgr, (det_w, det_h), interpolation=cv2.INTER_LINEAR)
|
|
||||||
inv_scale = 1.0 / det_scale
|
|
||||||
size_range_det = (
|
|
||||||
max(4, int(getattr(config, "TRIANGLE_SIZE_RANGE", (8, 500))[0] * det_scale)),
|
|
||||||
max(8, int(getattr(config, "TRIANGLE_SIZE_RANGE", (8, 500))[1] * det_scale)),
|
|
||||||
)
|
|
||||||
else:
|
|
||||||
img_det = img_bgr
|
|
||||||
inv_scale = 1.0
|
|
||||||
size_range_det = getattr(config, "TRIANGLE_SIZE_RANGE", (8, 500))
|
|
||||||
|
|
||||||
gray = cv2.cvtColor(img_det, cv2.COLOR_BGR2GRAY)
|
|
||||||
markers_det = detect_triangle_markers(
|
|
||||||
gray,
|
|
||||||
orig_gray=gray,
|
|
||||||
size_range=size_range_det,
|
|
||||||
verbose=bool(args.verbose),
|
|
||||||
)
|
|
||||||
if inv_scale != 1.0 and markers_det:
|
|
||||||
for m in markers_det:
|
|
||||||
m["center"] = [m["center"][0] * inv_scale, m["center"][1] * inv_scale]
|
|
||||||
m["corners"] = [[c[0] * inv_scale, c[1] * inv_scale] for c in m["corners"]]
|
|
||||||
|
|
||||||
print("[tri-test] markers_found:", len(markers_det), "ids:", [m.get("id") for m in markers_det])
|
|
||||||
|
|
||||||
t0 = time.time()
|
|
||||||
tri = try_triangle_scoring(
|
|
||||||
img_rgb, # try_triangle_scoring 期望 RGB
|
|
||||||
laser_point,
|
|
||||||
pos,
|
|
||||||
K,
|
|
||||||
dist,
|
|
||||||
size_range=getattr(config, "TRIANGLE_SIZE_RANGE", (8, 500)),
|
|
||||||
)
|
|
||||||
dt_ms = int(round((time.time() - t0) * 1000))
|
|
||||||
|
|
||||||
print("[tri-test] elapsed_ms:", dt_ms)
|
|
||||||
print(json.dumps(tri, ensure_ascii=False, indent=2))
|
|
||||||
|
|
||||||
if args.out:
|
|
||||||
out_path = args.out
|
|
||||||
# 允许传目录(如 ./),自动生成文件名;未带扩展名时默认 .jpg
|
|
||||||
if out_path.endswith("/") or out_path.endswith("\\") or os.path.isdir(out_path):
|
|
||||||
out_path = os.path.join(out_path, "tri_out.jpg")
|
|
||||||
root, ext = os.path.splitext(out_path)
|
|
||||||
if not ext:
|
|
||||||
out_path = root + ".jpg"
|
|
||||||
|
|
||||||
# 若 try_triangle_scoring 失败且没带回 markers,至少把候选 markers 画出来,方便肉眼判断
|
|
||||||
tri_for_draw = tri if isinstance(tri, dict) else {"ok": False}
|
|
||||||
if not tri_for_draw.get("markers") and markers_det:
|
|
||||||
tri_for_draw = dict(tri_for_draw)
|
|
||||||
tri_for_draw["markers"] = markers_det
|
|
||||||
out_img = _draw_tri_debug(img_bgr, tri_for_draw)
|
|
||||||
ok = cv2.imwrite(out_path, out_img)
|
|
||||||
if not ok:
|
|
||||||
raise SystemExit(f"写图失败(可能是不支持的扩展名):{out_path}")
|
|
||||||
print("[tri-test] wrote:", out_path)
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
main()
|
|
||||||
|
|
||||||
@@ -1,257 +0,0 @@
|
|||||||
#!/usr/bin/env python3
|
|
||||||
# -*- coding: utf-8 -*-
|
|
||||||
"""
|
|
||||||
本地图片 → Maix YOLOv5 检测 → 画框保存(用于核对坐标 mode / 多框 union)。
|
|
||||||
|
|
||||||
运行环境:MaixCAM / MaixPy(需 maix.image / maix.nn),在项目根或任意目录执行均可。
|
|
||||||
|
|
||||||
示例:
|
|
||||||
python test/test_yolo_draw_boxes.py /root/phot/shot_xxx.jpg
|
|
||||||
python test/test_yolo_draw_boxes.py shot.jpg --loader cv2_rgb --conf 0.25
|
|
||||||
python test/test_yolo_draw_boxes.py shot.jpg --debug
|
|
||||||
python -h # 查看 --loader / --debug / --union 等全部参数
|
|
||||||
|
|
||||||
脚本版本(与设备同步用):20260206-yolo-vis
|
|
||||||
"""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import argparse
|
|
||||||
import os
|
|
||||||
import sys
|
|
||||||
|
|
||||||
_ROOT = os.path.abspath(os.path.join(os.path.dirname(__file__), ".."))
|
|
||||||
if _ROOT not in sys.path:
|
|
||||||
sys.path.insert(0, _ROOT)
|
|
||||||
|
|
||||||
|
|
||||||
def _load_maix_image(path: str, image_mod):
|
|
||||||
"""maix.image.load(部分 JPEG 解码后与 camera.read() 像素布局不一致,可能导致 NPU 全空)。"""
|
|
||||||
return image_mod.load(path)
|
|
||||||
|
|
||||||
|
|
||||||
def _load_cv2_rgb_as_maix(path: str, image_mod):
|
|
||||||
"""
|
|
||||||
OpenCV 读盘为 BGR → 转 RGB → 与 shoot_manager 里 image2cv 逆过程一致,供 YOLO input type: rgb。
|
|
||||||
"""
|
|
||||||
import cv2
|
|
||||||
|
|
||||||
arr = cv2.imread(path, cv2.IMREAD_COLOR)
|
|
||||||
if arr is None:
|
|
||||||
raise FileNotFoundError(f"cv2.imread 失败: {path}")
|
|
||||||
arr = cv2.cvtColor(arr, cv2.COLOR_BGR2RGB)
|
|
||||||
return image_mod.cv2image(arr, False, False)
|
|
||||||
|
|
||||||
|
|
||||||
def main():
|
|
||||||
ap = argparse.ArgumentParser(
|
|
||||||
description="YOLO 画框测试(Maix)",
|
|
||||||
formatter_class=argparse.RawDescriptionHelpFormatter,
|
|
||||||
epilog="若提示 unrecognized arguments: --debug,说明设备上脚本未更新,请同步仓库中的 test/test_yolo_draw_boxes.py",
|
|
||||||
)
|
|
||||||
ap.add_argument("image", help="输入图片路径")
|
|
||||||
ap.add_argument("-o", "--output", default="", help="输出图片路径;默认 原名_yolo_vis.jpg")
|
|
||||||
ap.add_argument("-m", "--model", default="", help="覆盖 config.TRIANGLE_YOLO_MODEL_PATH")
|
|
||||||
ap.add_argument("--conf", type=float, default=None, help="置信度阈值")
|
|
||||||
ap.add_argument("--iou", type=float, default=None, help="NMS IoU")
|
|
||||||
ap.add_argument(
|
|
||||||
"--coord",
|
|
||||||
choices=["native", "letterbox"],
|
|
||||||
default="",
|
|
||||||
help="坐标映射;默认读 config.TRIANGLE_YOLO_COORD_MODE",
|
|
||||||
)
|
|
||||||
ap.add_argument(
|
|
||||||
"--union",
|
|
||||||
action="store_true",
|
|
||||||
help="按 TRIANGLE_YOLO_RING_CLASS_IDS 过滤后画合并外接矩形(与线上 ROI merge=union 一致)",
|
|
||||||
)
|
|
||||||
ap.add_argument(
|
|
||||||
"--loader",
|
|
||||||
choices=["auto", "maix", "cv2_rgb"],
|
|
||||||
default="auto",
|
|
||||||
help="auto: 先 maix.load,0 框则改用 cv2 RGB(推荐排查「有图但始终 0 框」)",
|
|
||||||
)
|
|
||||||
ap.add_argument(
|
|
||||||
"--debug",
|
|
||||||
action="store_true",
|
|
||||||
help="打印 detect 原始返回类型与 repr(截断)",
|
|
||||||
)
|
|
||||||
args = ap.parse_args()
|
|
||||||
|
|
||||||
try:
|
|
||||||
from maix import image, nn
|
|
||||||
except ImportError:
|
|
||||||
print("[ERR] 需要 MaixPy(maix.image / maix.nn),请在 MaixCAM 上运行。")
|
|
||||||
sys.exit(1)
|
|
||||||
|
|
||||||
import config as cfg
|
|
||||||
import target_roi_yolo as yroi
|
|
||||||
|
|
||||||
img_path = os.path.abspath(args.image)
|
|
||||||
if not os.path.isfile(img_path):
|
|
||||||
print(f"[ERR] 找不到图片: {img_path}")
|
|
||||||
sys.exit(1)
|
|
||||||
|
|
||||||
model_path = (args.model or getattr(cfg, "TRIANGLE_YOLO_MODEL_PATH", "") or "").strip()
|
|
||||||
if not os.path.isfile(model_path):
|
|
||||||
print(f"[ERR] 模型文件不存在: {model_path}")
|
|
||||||
sys.exit(1)
|
|
||||||
|
|
||||||
conf_th = (
|
|
||||||
float(args.conf)
|
|
||||||
if args.conf is not None
|
|
||||||
else float(getattr(cfg, "TRIANGLE_YOLO_CONF_TH", 0.5))
|
|
||||||
)
|
|
||||||
iou_th = (
|
|
||||||
float(args.iou)
|
|
||||||
if args.iou is not None
|
|
||||||
else float(getattr(cfg, "TRIANGLE_YOLO_IOU_TH", 0.45))
|
|
||||||
)
|
|
||||||
coord_mode = (args.coord or getattr(cfg, "TRIANGLE_YOLO_COORD_MODE", "native")).lower()
|
|
||||||
|
|
||||||
out_path = args.output.strip()
|
|
||||||
if not out_path:
|
|
||||||
base, ext = os.path.splitext(img_path)
|
|
||||||
ext = ext if ext else ".jpg"
|
|
||||||
out_path = base + "_yolo_vis" + ext
|
|
||||||
|
|
||||||
det = nn.YOLOv5(model=model_path, dual_buff=False)
|
|
||||||
net_w = int(det.input_width())
|
|
||||||
net_h = int(det.input_height())
|
|
||||||
|
|
||||||
def _run_detect(maix_img, tag: str):
|
|
||||||
r = det.detect(maix_img, conf_th=conf_th, iou_th=iou_th)
|
|
||||||
if args.debug:
|
|
||||||
rlen = len(r) if r is not None and hasattr(r, "__len__") else "n/a"
|
|
||||||
rrepr = repr(r)
|
|
||||||
if len(rrepr) > 300:
|
|
||||||
rrepr = rrepr[:300] + "..."
|
|
||||||
print(f"[DEBUG] loader={tag} raw_type={type(r)} len={rlen} repr={rrepr}")
|
|
||||||
return yroi._normalize_objs(r if r is not None else []), maix_img, tag
|
|
||||||
|
|
||||||
img = None
|
|
||||||
load_tag = ""
|
|
||||||
objs = []
|
|
||||||
|
|
||||||
if args.loader == "cv2_rgb":
|
|
||||||
img = _load_cv2_rgb_as_maix(img_path, image)
|
|
||||||
load_tag = "cv2_rgb"
|
|
||||||
objs, img, load_tag = _run_detect(img, load_tag)
|
|
||||||
elif args.loader == "maix":
|
|
||||||
img = _load_maix_image(img_path, image)
|
|
||||||
load_tag = "maix_load"
|
|
||||||
objs, img, load_tag = _run_detect(img, load_tag)
|
|
||||||
else:
|
|
||||||
# auto
|
|
||||||
img = _load_maix_image(img_path, image)
|
|
||||||
load_tag = "maix_load"
|
|
||||||
objs, img, load_tag = _run_detect(img, load_tag)
|
|
||||||
if len(objs) == 0:
|
|
||||||
print(
|
|
||||||
"[WARN] maix.image.load 在 conf_th=%s 下仍为 0 框,改用 cv2 BGR→RGB→cv2image 重试(常见可恢复)"
|
|
||||||
% conf_th
|
|
||||||
)
|
|
||||||
img2 = _load_cv2_rgb_as_maix(img_path, image)
|
|
||||||
objs, img, load_tag = _run_detect(img2, "cv2_rgb_retry")
|
|
||||||
|
|
||||||
src_w, src_h = img.width(), img.height()
|
|
||||||
|
|
||||||
labels = getattr(det, "labels", None)
|
|
||||||
|
|
||||||
def _label(cid: int) -> str:
|
|
||||||
if labels is None:
|
|
||||||
return str(cid)
|
|
||||||
try:
|
|
||||||
return str(labels[int(cid)])
|
|
||||||
except Exception:
|
|
||||||
return str(cid)
|
|
||||||
|
|
||||||
print(
|
|
||||||
f"[INFO] loader={load_tag} image={src_w}×{src_h}, net_in={net_w}×{net_h}, "
|
|
||||||
f"coord={coord_mode}, conf_th={conf_th}, iou_th={iou_th}"
|
|
||||||
)
|
|
||||||
print(f"[INFO] NMS 后检测框数量={len(objs)} → {out_path}")
|
|
||||||
if len(objs) == 0:
|
|
||||||
print(
|
|
||||||
"[HINT] 仍为 0 框时常见原因:\n"
|
|
||||||
" 1) 强制 cv2 路径: --loader cv2_rgb\n"
|
|
||||||
" 2) NMS 过严: --iou 0.95\n"
|
|
||||||
" 3) 图与训练分布差太大 / 模型未见过该场景\n"
|
|
||||||
" 4) 用 camera.read() 一帧存盘再测,对比 file 与实时是否一致"
|
|
||||||
)
|
|
||||||
|
|
||||||
# 颜色:按类别轮换(仅有 COLOR_* 时常量时用)
|
|
||||||
color_cycle = []
|
|
||||||
for name in ("RED", "GREEN", "BLUE", "ORANGE", "YELLOW", "CYAN", "MAGENTA"):
|
|
||||||
c = getattr(image, f"COLOR_{name}", None)
|
|
||||||
if c is not None:
|
|
||||||
color_cycle.append(c)
|
|
||||||
if not color_cycle:
|
|
||||||
color_cycle = [getattr(image, "COLOR_RED", 0)]
|
|
||||||
|
|
||||||
for i, o in enumerate(objs):
|
|
||||||
cid = yroi._det_obj_class_id(o)
|
|
||||||
if cid is None:
|
|
||||||
cid = -1
|
|
||||||
try:
|
|
||||||
sc = float(o.score)
|
|
||||||
except Exception:
|
|
||||||
sc = 0.0
|
|
||||||
x0, y0, x1, y1 = yroi._det_to_src_xyxy(o, coord_mode, src_w, src_h, net_w, net_h)
|
|
||||||
ix = int(max(0, min(x0, src_w - 1)))
|
|
||||||
iy = int(max(0, min(y0, src_h - 1)))
|
|
||||||
iw = int(max(1, min(x1 - x0, src_w - ix)))
|
|
||||||
ih = int(max(1, min(y1 - y0, src_h - iy)))
|
|
||||||
col = color_cycle[cid % len(color_cycle)] if cid >= 0 else color_cycle[0]
|
|
||||||
img.draw_rect(ix, iy, iw, ih, color=col)
|
|
||||||
ty = max(0, iy - 14)
|
|
||||||
msg = f"{_label(cid)} {sc:.2f}"
|
|
||||||
img.draw_string(ix, ty, msg, color=col)
|
|
||||||
print(f" #{i} cls={cid} {_label(cid)} score={sc:.3f} xywh=({ix},{iy},{iw},{ih})")
|
|
||||||
|
|
||||||
if args.union:
|
|
||||||
class_ids = getattr(cfg, "TRIANGLE_YOLO_RING_CLASS_IDS", (0,))
|
|
||||||
if isinstance(class_ids, int):
|
|
||||||
class_ids = (class_ids,)
|
|
||||||
cand = [o for o in objs if yroi._det_obj_class_id(o) in class_ids]
|
|
||||||
if cand:
|
|
||||||
xy_list = [
|
|
||||||
yroi._det_to_src_xyxy(o, coord_mode, src_w, src_h, net_w, net_h) for o in cand
|
|
||||||
]
|
|
||||||
merged = yroi._merge_roi_xyxy(xy_list, "union")
|
|
||||||
if merged:
|
|
||||||
mx0, my0, mx1, my1 = merged
|
|
||||||
mx0 = max(0, min(mx0, src_w - 1))
|
|
||||||
my0 = max(0, min(my0, src_h - 1))
|
|
||||||
mx1 = max(mx0 + 1, min(mx1, src_w))
|
|
||||||
my1 = max(my0 + 1, min(my1, src_h))
|
|
||||||
uw, uh = int(mx1 - mx0), int(my1 - my0)
|
|
||||||
ucol = getattr(image, "COLOR_GREEN", color_cycle[0])
|
|
||||||
# 画粗一点的 union:描两遍错位矩形简易模拟加粗
|
|
||||||
for d in (0, 2):
|
|
||||||
img.draw_rect(
|
|
||||||
int(mx0) - d,
|
|
||||||
int(my0) - d,
|
|
||||||
uw + 2 * d,
|
|
||||||
uh + 2 * d,
|
|
||||||
color=ucol,
|
|
||||||
)
|
|
||||||
img.draw_string(
|
|
||||||
int(mx0),
|
|
||||||
max(0, int(my0) - 28),
|
|
||||||
f"UNION ({len(cand)} boxes)",
|
|
||||||
color=ucol,
|
|
||||||
)
|
|
||||||
print(f"[INFO] UNION [{int(mx0)},{int(my0)},{int(mx1)},{int(my1)}] from {len(cand)} boxes")
|
|
||||||
else:
|
|
||||||
print("[WARN] --union 但 RING_CLASS_IDS 过滤后无框")
|
|
||||||
|
|
||||||
try:
|
|
||||||
img.save(out_path, quality=95)
|
|
||||||
except TypeError:
|
|
||||||
img.save(out_path)
|
|
||||||
print(f"[OK] saved: {out_path}")
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
main()
|
|
||||||
@@ -1,506 +0,0 @@
|
|||||||
#!/usr/bin/env python3
|
|
||||||
# -*- coding: utf-8 -*-
|
|
||||||
"""
|
|
||||||
YOLO11 关键点检测训练脚本(靶纸四角)。
|
|
||||||
|
|
||||||
设备优先级(--device auto):Intel XPU > NVIDIA CUDA > CPU。
|
|
||||||
默认 imgsz=960;批大小默认 4(大图显存紧张时可再降)。
|
|
||||||
|
|
||||||
关于「业务像素误差」:
|
|
||||||
Ultralytics 没有在 yaml 里设定「像素阈值」的选项;反向传播仍由 pose/kobj/box 等内部 loss 驱动。
|
|
||||||
- 监控:--pixel-metrics-every N(每 N 个 epoch 打印 mean/p95,并合并进 runs/.../results.csv,见 pose_pixel_metrics.py)。
|
|
||||||
- 选 best.pt / early stopping:加 --best-by-pixel,用验证集 mean 像素误差(与 pose_pixel_metrics
|
|
||||||
同一口径)代替 mAP 合成 fitness(fitness = -mean_px,越小越好)。
|
|
||||||
多卡 DDP(world_size>1)时会自动退回默认 mAP fitness。
|
|
||||||
|
|
||||||
XPU:Ultralytics BaseTrainer._get_memory / _clear_memory 把非 MPS、非 CPU 一律当 CUDA,
|
|
||||||
会在验证前调用 torch.cuda 而报错;本脚本在选用 XPU 时自动打补丁(见 _patch_ultralytics_trainer_for_xpu)。
|
|
||||||
|
|
||||||
务必使用 pose 任务:YOLO(...) 与 model.train(...) 均指定 task='pose'。若误用默认 detect,
|
|
||||||
会把 17 列 Pose 标注当成检测/分割解析,校验时出现「coordinates > 1」或 [2.] 等假象。
|
|
||||||
"""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import argparse
|
|
||||||
import csv
|
|
||||||
import gc
|
|
||||||
import glob
|
|
||||||
import os
|
|
||||||
import tempfile
|
|
||||||
from copy import deepcopy
|
|
||||||
from pathlib import Path
|
|
||||||
|
|
||||||
import torch
|
|
||||||
from ultralytics import YOLO
|
|
||||||
|
|
||||||
from pose_pixel_metrics import eval_val_pixel_error
|
|
||||||
import warnings
|
|
||||||
warnings.filterwarnings('ignore',
|
|
||||||
message=".*scatter_add_kernel does not have a deterministic implementation.*")
|
|
||||||
|
|
||||||
|
|
||||||
def _clear_ultralytics_label_caches(data_yaml_path: str) -> int:
|
|
||||||
"""删除 data.yaml 的 path 下 labels/*.cache。
|
|
||||||
|
|
||||||
Ultralytics 的校验缓存 hash 仅依赖「标签/图片路径字符串 + 各文件 size 之和」,不含文件内容;
|
|
||||||
修正 *.txt 后若总和巧合不变,可能继续加载旧 cache 并重播旧的 corrupt 日志,训练前应删掉。"""
|
|
||||||
from ultralytics.utils import YAML
|
|
||||||
|
|
||||||
try:
|
|
||||||
cfg = YAML.load(data_yaml_path)
|
|
||||||
except Exception:
|
|
||||||
return 0
|
|
||||||
root = cfg.get("path")
|
|
||||||
if not root:
|
|
||||||
return 0
|
|
||||||
root = os.path.abspath(os.path.expanduser(str(root)))
|
|
||||||
pattern = os.path.join(root, "labels", "*.cache")
|
|
||||||
n = 0
|
|
||||||
for p in glob.glob(pattern):
|
|
||||||
try:
|
|
||||||
os.unlink(p)
|
|
||||||
n += 1
|
|
||||||
except OSError:
|
|
||||||
pass
|
|
||||||
return n
|
|
||||||
|
|
||||||
|
|
||||||
def _pick_device(explicit: str | None):
|
|
||||||
"""返回 ultralytics train/predict 可用的 device。"""
|
|
||||||
if explicit and explicit != "auto":
|
|
||||||
e = explicit.lower()
|
|
||||||
if e == "xpu":
|
|
||||||
if getattr(torch, "xpu", None) is None or not torch.xpu.is_available():
|
|
||||||
raise RuntimeError("指定了 --device xpu 但当前环境不可用")
|
|
||||||
return torch.device("xpu")
|
|
||||||
if e in ("0", "cuda", "gpu"):
|
|
||||||
if not torch.cuda.is_available():
|
|
||||||
raise RuntimeError("指定了 CUDA 但不可用")
|
|
||||||
return 0
|
|
||||||
if e == "cpu":
|
|
||||||
return "cpu"
|
|
||||||
return explicit
|
|
||||||
if getattr(torch, "xpu", None) is not None and torch.xpu.is_available():
|
|
||||||
return torch.device("xpu")
|
|
||||||
if torch.cuda.is_available():
|
|
||||||
return 0
|
|
||||||
return "cpu"
|
|
||||||
|
|
||||||
|
|
||||||
def _default_amp(device) -> bool:
|
|
||||||
if isinstance(device, torch.device) and device.type == "xpu":
|
|
||||||
return False
|
|
||||||
if device == "cpu":
|
|
||||||
return False
|
|
||||||
return True
|
|
||||||
|
|
||||||
|
|
||||||
def _patch_ultralytics_for_xpu():
|
|
||||||
"""为 Ultralytics 打补丁,使其能在 XPU 环境下正常训练和验证。"""
|
|
||||||
import ultralytics.engine.trainer as ut_trainer
|
|
||||||
import ultralytics.engine.validator as ut_validator
|
|
||||||
from ultralytics.utils.torch_utils import select_device as _original_select_device
|
|
||||||
|
|
||||||
# 1. 覆盖 select_device:Trainer 初始化传入 torch.device("xpu") 会走原版早返回;
|
|
||||||
# 初始化后 args.device 会变成字符串 "xpu",中期 val 用 trainer.device,不调用 select_device;
|
|
||||||
# 训练结束 final_eval 里 Validator 会 select_device("xpu"),且 validator 在 import 时已绑定原函数,
|
|
||||||
# 只改 torch_utils 无效,必须同时修补 trainer/validator 模块内的引用。
|
|
||||||
def _patched_select_device(device="", *args, **kwargs):
|
|
||||||
# Ultralytics 8.4.x: select_device(device="", newline=False, verbose=True)
|
|
||||||
# Older forks sometimes passed extra positional args; forward everything.
|
|
||||||
if isinstance(device, str):
|
|
||||||
d = device.strip().lower()
|
|
||||||
if d == "xpu" or d.startswith("xpu:"):
|
|
||||||
return torch.device(device.strip())
|
|
||||||
return _original_select_device(device, *args, **kwargs)
|
|
||||||
|
|
||||||
import ultralytics.utils.torch_utils
|
|
||||||
|
|
||||||
ultralytics.utils.torch_utils.select_device = _patched_select_device
|
|
||||||
ut_trainer.select_device = _patched_select_device
|
|
||||||
ut_validator.select_device = _patched_select_device
|
|
||||||
|
|
||||||
# 2. 修补 Trainer 的内存函数
|
|
||||||
BT = ut_trainer.BaseTrainer
|
|
||||||
if not getattr(BT, "_archery_xpu_memory_patched", False):
|
|
||||||
_orig_get_memory = BT._get_memory
|
|
||||||
_orig_clear_memory = BT._clear_memory
|
|
||||||
|
|
||||||
def _get_memory(self, fraction=False):
|
|
||||||
if self.device.type != "xpu":
|
|
||||||
return _orig_get_memory(self, fraction)
|
|
||||||
# ... (原有的 XPU 内存获取逻辑保持不变) ...
|
|
||||||
memory, total = 0, 0
|
|
||||||
try:
|
|
||||||
idx = self.device.index
|
|
||||||
if idx is None:
|
|
||||||
idx = torch.xpu.current_device()
|
|
||||||
memory = int(torch.xpu.memory_allocated(idx))
|
|
||||||
if fraction:
|
|
||||||
total = int(torch.xpu.get_device_properties(idx).total_memory)
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
return (memory / total) if fraction and total > 0 else (memory / 2**30)
|
|
||||||
|
|
||||||
def _clear_memory(self, threshold=None):
|
|
||||||
if self.device.type != "xpu":
|
|
||||||
return _orig_clear_memory(self, threshold)
|
|
||||||
if threshold is not None:
|
|
||||||
assert 0 <= threshold <= 1, "Threshold must be between 0 and 1."
|
|
||||||
if self._get_memory(fraction=True) <= threshold:
|
|
||||||
return
|
|
||||||
gc.collect()
|
|
||||||
if hasattr(torch.xpu, "empty_cache"):
|
|
||||||
torch.xpu.empty_cache()
|
|
||||||
|
|
||||||
BT._get_memory = _get_memory
|
|
||||||
BT._clear_memory = _clear_memory
|
|
||||||
BT._archery_xpu_memory_patched = True
|
|
||||||
|
|
||||||
# 3. 修补 Validator 的内存函数 (关键是添加这部分)
|
|
||||||
BV = ut_validator.BaseValidator
|
|
||||||
if not getattr(BV, "_archery_xpu_memory_patched", False):
|
|
||||||
# 为 Validator 添加同样的内存处理方法
|
|
||||||
BV._get_memory = _get_memory
|
|
||||||
BV._clear_memory = _clear_memory
|
|
||||||
BV._archery_xpu_memory_patched = True
|
|
||||||
|
|
||||||
|
|
||||||
def _install_best_by_pixel_validate(data_yaml: str, imgsz: int, conf: float) -> None:
|
|
||||||
"""用验证集关键点像素 mean 替代 mAP fitness,驱动 best.pt 与 patience early stopping。"""
|
|
||||||
import ultralytics.engine.trainer as ut
|
|
||||||
from ultralytics.utils import RANK
|
|
||||||
|
|
||||||
BT = ut.BaseTrainer
|
|
||||||
if getattr(BT, "_archery_best_by_pixel_installed", False):
|
|
||||||
return
|
|
||||||
|
|
||||||
_orig_validate = BT.validate
|
|
||||||
|
|
||||||
def validate(self):
|
|
||||||
import torch.distributed as dist
|
|
||||||
|
|
||||||
if self.ema and self.world_size > 1:
|
|
||||||
for buffer in self.ema.ema.buffers():
|
|
||||||
dist.broadcast(buffer, src=0)
|
|
||||||
metrics = self.validator(self)
|
|
||||||
if metrics is None:
|
|
||||||
return None, None
|
|
||||||
orig_fitness = metrics.pop("fitness", -self.loss.detach().cpu().numpy())
|
|
||||||
|
|
||||||
use_pixel = self.world_size <= 1 and RANK in {-1, 0}
|
|
||||||
mean_px: float | None = None
|
|
||||||
if use_pixel:
|
|
||||||
tmp_path: str | None = None
|
|
||||||
try:
|
|
||||||
fd, tmp_path = tempfile.mkstemp(suffix=".pt", prefix="archery_pxfit_")
|
|
||||||
os.close(fd)
|
|
||||||
from ultralytics.utils.torch_utils import unwrap_model
|
|
||||||
|
|
||||||
core = unwrap_model(self.ema.ema if self.ema else self.model)
|
|
||||||
torch.save({"ema": deepcopy(core).half(), "train_args": vars(self.args)}, tmp_path)
|
|
||||||
probe = YOLO(tmp_path)
|
|
||||||
stats = eval_val_pixel_error(
|
|
||||||
probe,
|
|
||||||
data_yaml,
|
|
||||||
device=self.device,
|
|
||||||
imgsz=imgsz,
|
|
||||||
conf=conf,
|
|
||||||
)
|
|
||||||
mean_px = stats.get("mean_px")
|
|
||||||
if mean_px is None:
|
|
||||||
raise RuntimeError("无有效 mean_px(检查 val 标签与检测是否为空)")
|
|
||||||
except Exception as exc:
|
|
||||||
print(f"\n⚠️ [best-by-pixel] 像素探针失败,本 epoch 仍用 mAP fitness: {exc}\n")
|
|
||||||
mean_px = None
|
|
||||||
finally:
|
|
||||||
if tmp_path:
|
|
||||||
try:
|
|
||||||
os.unlink(tmp_path)
|
|
||||||
except OSError:
|
|
||||||
pass
|
|
||||||
|
|
||||||
if mean_px is not None:
|
|
||||||
fitness = -float(mean_px)
|
|
||||||
metrics["metrics/mean_px(val)"] = float(mean_px)
|
|
||||||
else:
|
|
||||||
fitness = float(orig_fitness)
|
|
||||||
|
|
||||||
if not self.best_fitness or self.best_fitness < fitness:
|
|
||||||
self.best_fitness = fitness
|
|
||||||
return metrics, fitness
|
|
||||||
|
|
||||||
BT.validate = validate
|
|
||||||
BT._archery_best_by_pixel_installed = True
|
|
||||||
|
|
||||||
|
|
||||||
def _fmt_csv_metric(v: float | int | None) -> str:
|
|
||||||
if v is None:
|
|
||||||
return ""
|
|
||||||
if isinstance(v, float):
|
|
||||||
return f"{v:.6g}"
|
|
||||||
return str(v)
|
|
||||||
|
|
||||||
|
|
||||||
# 写入 results.csv 的列名(与 --best-by-pixel 的 metrics/mean_px(val) 区分,避免被 last.pt 回调覆盖 EMA 行)
|
|
||||||
_PIXEL_METRIC_COLUMNS: tuple[tuple[str, str], ...] = (
|
|
||||||
("pixel_error/mean_px", "mean_px"),
|
|
||||||
("pixel_error/median_px", "median_px"),
|
|
||||||
("pixel_error/p95_px", "p95_px"),
|
|
||||||
("pixel_error/max_px", "max_px"),
|
|
||||||
("pixel_error/n_points", "n_points"),
|
|
||||||
("pixel_error/n_images", "n_images"),
|
|
||||||
("pixel_error/skip_no_det", "skip_no_det"),
|
|
||||||
("pixel_error/skip_no_gt", "skip_no_gt"),
|
|
||||||
("pixel_error/skip_kpt_mismatch", "skip_kpt_mismatch"),
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def _merge_pixel_metrics_into_results_csv(save_dir: str | Path, epoch_1based: int, stats: dict) -> None:
|
|
||||||
"""在 Ultralytics 写完本 epoch 行之后,把像素指标列合并进 results.csv(扩展表头、补空列)。"""
|
|
||||||
csv_path = Path(save_dir) / "results.csv"
|
|
||||||
if not csv_path.is_file():
|
|
||||||
return
|
|
||||||
try:
|
|
||||||
with open(csv_path, newline="", encoding="utf-8") as f:
|
|
||||||
rows = list(csv.reader(f))
|
|
||||||
except OSError:
|
|
||||||
return
|
|
||||||
if len(rows) < 2:
|
|
||||||
return
|
|
||||||
header = list(rows[0])
|
|
||||||
for col_name, _ in _PIXEL_METRIC_COLUMNS:
|
|
||||||
if col_name not in header:
|
|
||||||
header.append(col_name)
|
|
||||||
for ri in range(1, len(rows)):
|
|
||||||
rows[ri].append("")
|
|
||||||
col_ix = {name: i for i, name in enumerate(header)}
|
|
||||||
rows[0] = header
|
|
||||||
target_ri: int | None = None
|
|
||||||
for ri in range(1, len(rows)):
|
|
||||||
row = rows[ri]
|
|
||||||
while len(row) < len(header):
|
|
||||||
row.append("")
|
|
||||||
try:
|
|
||||||
if int(float(row[0].strip())) == int(epoch_1based):
|
|
||||||
target_ri = ri
|
|
||||||
except (ValueError, IndexError):
|
|
||||||
continue
|
|
||||||
if target_ri is None:
|
|
||||||
return
|
|
||||||
row = rows[target_ri]
|
|
||||||
while len(row) < len(header):
|
|
||||||
row.append("")
|
|
||||||
for col_name, sk in _PIXEL_METRIC_COLUMNS:
|
|
||||||
row[col_ix[col_name]] = _fmt_csv_metric(stats.get(sk))
|
|
||||||
try:
|
|
||||||
with open(csv_path, "w", newline="", encoding="utf-8") as f:
|
|
||||||
w = csv.writer(f)
|
|
||||||
w.writerows(rows)
|
|
||||||
except OSError:
|
|
||||||
pass
|
|
||||||
|
|
||||||
|
|
||||||
def _make_pixel_metrics_callback(data_yaml: str, every: int, imgsz: int, conf: float = 0.25):
|
|
||||||
def on_fit_epoch_end(trainer):
|
|
||||||
from ultralytics.utils import RANK
|
|
||||||
|
|
||||||
if RANK not in {-1, 0}:
|
|
||||||
return
|
|
||||||
if every <= 0:
|
|
||||||
return
|
|
||||||
ep = int(getattr(trainer, "epoch", -1))
|
|
||||||
if (ep + 1) % every != 0:
|
|
||||||
return
|
|
||||||
w = Path(trainer.save_dir) / "weights" / "last.pt"
|
|
||||||
if not w.is_file():
|
|
||||||
return
|
|
||||||
m = YOLO(str(w))
|
|
||||||
stats = eval_val_pixel_error(
|
|
||||||
m,
|
|
||||||
data_yaml,
|
|
||||||
device=trainer.device,
|
|
||||||
imgsz=imgsz,
|
|
||||||
conf=conf,
|
|
||||||
)
|
|
||||||
mean_px = stats.get("mean_px")
|
|
||||||
p95_px = stats.get("p95_px")
|
|
||||||
mean_s = f"{mean_px:.3f}" if mean_px is not None else "n/a"
|
|
||||||
p95_s = f"{p95_px:.3f}" if p95_px is not None else "n/a"
|
|
||||||
print(
|
|
||||||
f"\n[pixel-metrics] epoch {ep + 1}: mean_px={mean_s} p95_px={p95_s} "
|
|
||||||
f"n_points={stats.get('n_points', 0)} "
|
|
||||||
f"skip(det/gt/k)={stats['skip_no_det']}/{stats['skip_no_gt']}/{stats['skip_kpt_mismatch']}\n"
|
|
||||||
)
|
|
||||||
_merge_pixel_metrics_into_results_csv(trainer.save_dir, ep + 1, stats)
|
|
||||||
|
|
||||||
return on_fit_epoch_end
|
|
||||||
|
|
||||||
|
|
||||||
def main():
|
|
||||||
ap = argparse.ArgumentParser(description="YOLO Pose 训练(XPU/CUDA/CPU)")
|
|
||||||
ap.add_argument("--data", default="datasets/dataset_pose.yaml", help="data.yaml")
|
|
||||||
ap.add_argument("--model", default="yolo11x-pose.pt", help="预训练权重")
|
|
||||||
ap.add_argument("--epochs", type=int, default=100)
|
|
||||||
ap.add_argument("--imgsz", type=int, default=960, help="训练输入边长(默认 960)")
|
|
||||||
ap.add_argument("--batch", type=int, default=4, help="批大小;OOM 时减小")
|
|
||||||
ap.add_argument(
|
|
||||||
"--device",
|
|
||||||
default="auto",
|
|
||||||
help="auto | xpu | 0 | cuda | cpu(auto:XPU 优先)",
|
|
||||||
)
|
|
||||||
ap.add_argument(
|
|
||||||
"--no-amp",
|
|
||||||
action="store_true",
|
|
||||||
help="关闭混合精度(默认:CUDA 开启,XPU/CPU 关闭)",
|
|
||||||
)
|
|
||||||
ap.add_argument("--project", default="runs/pose")
|
|
||||||
ap.add_argument("--name", default="target_pose_train")
|
|
||||||
ap.add_argument("--workers", type=int, default=4)
|
|
||||||
ap.add_argument(
|
|
||||||
"--pixel-metrics-every",
|
|
||||||
type=int,
|
|
||||||
default=0,
|
|
||||||
help="每 N 个 epoch 在 val 上打印像素误差并写入 results.csv 对应 epoch 行(0=关闭);需 labels 与 data.yaml 布局一致",
|
|
||||||
)
|
|
||||||
ap.add_argument(
|
|
||||||
"--pixel-metrics-conf",
|
|
||||||
type=float,
|
|
||||||
default=0.25,
|
|
||||||
help="--pixel-metrics-every 时 predict 置信度阈值(默认 0.25)",
|
|
||||||
)
|
|
||||||
ap.add_argument(
|
|
||||||
"--best-by-pixel",
|
|
||||||
action="store_true",
|
|
||||||
help="best.pt 与 early stopping 按验证集 mean 像素误差(同 pose_pixel_metrics),fitness=-mean_px;单卡有效,DDP 自动退回 mAP",
|
|
||||||
)
|
|
||||||
ap.add_argument(
|
|
||||||
"--pixel-fitness-conf",
|
|
||||||
type=float,
|
|
||||||
default=0.25,
|
|
||||||
help="--best-by-pixel 时 predict 置信度阈值(默认与 pixel-metrics 一致)",
|
|
||||||
)
|
|
||||||
ap.add_argument(
|
|
||||||
"--export-onnx",
|
|
||||||
action="store_true",
|
|
||||||
help="训练结束后导出 ONNX(需再设 --onnx-imgsz)",
|
|
||||||
)
|
|
||||||
ap.add_argument(
|
|
||||||
"--onnx-imgsz",
|
|
||||||
type=int,
|
|
||||||
nargs=2,
|
|
||||||
metavar=("H", "W"),
|
|
||||||
default=[224, 320],
|
|
||||||
help="导出 ONNX 的 [高, 宽],默认 224 320(Maix 常用)",
|
|
||||||
)
|
|
||||||
ap.add_argument(
|
|
||||||
"--clear-label-cache",
|
|
||||||
action="store_true",
|
|
||||||
help="启动训练前删除 data.yaml 中 path 下的 labels/*.cache(修正标注后仍报 corrupt 时用)",
|
|
||||||
)
|
|
||||||
args = ap.parse_args()
|
|
||||||
|
|
||||||
device = _pick_device(None if args.device == "auto" else args.device)
|
|
||||||
use_amp = False if args.no_amp else _default_amp(device)
|
|
||||||
|
|
||||||
if isinstance(device, torch.device) and device.type == "xpu":
|
|
||||||
print(f"✅ 使用 Intel XPU: {device}")
|
|
||||||
elif device == 0 or device == "0":
|
|
||||||
print(f"✅ 使用 CUDA: {torch.cuda.get_device_name(0)}")
|
|
||||||
else:
|
|
||||||
print("⚠️ 使用 CPU,训练会较慢")
|
|
||||||
|
|
||||||
if isinstance(device, torch.device) and device.type == "xpu":
|
|
||||||
_patch_ultralytics_for_xpu()
|
|
||||||
|
|
||||||
data_yaml = args.data
|
|
||||||
if not os.path.isabs(data_yaml):
|
|
||||||
data_yaml = os.path.join(os.path.dirname(os.path.abspath(__file__)), data_yaml)
|
|
||||||
if not os.path.exists(data_yaml):
|
|
||||||
print(f"❌ 数据集配置不存在: {data_yaml}")
|
|
||||||
return
|
|
||||||
|
|
||||||
if args.clear_label_cache:
|
|
||||||
n_rm = _clear_ultralytics_label_caches(data_yaml)
|
|
||||||
print(f"🗑️ 已删除标签目录缓存 {n_rm} 个(labels/*.cache),将强制重新扫描标注。")
|
|
||||||
|
|
||||||
print(f"📦 加载模型: {args.model}(固定 task=pose)")
|
|
||||||
model = YOLO(args.model, task="pose")
|
|
||||||
|
|
||||||
if args.best_by_pixel:
|
|
||||||
_install_best_by_pixel_validate(data_yaml, args.imgsz, args.pixel_fitness_conf)
|
|
||||||
print(
|
|
||||||
"📌 已启用 --best-by-pixel:best.pt / patience 按验证集 mean 像素误差(fitness=-mean_px);"
|
|
||||||
"反向传播仍为 Ultralytics 默认 pose/box loss。"
|
|
||||||
)
|
|
||||||
|
|
||||||
if args.pixel_metrics_every > 0:
|
|
||||||
model.add_callback(
|
|
||||||
"on_fit_epoch_end",
|
|
||||||
_make_pixel_metrics_callback(
|
|
||||||
data_yaml, args.pixel_metrics_every, args.imgsz, conf=args.pixel_metrics_conf
|
|
||||||
),
|
|
||||||
)
|
|
||||||
|
|
||||||
model.train(
|
|
||||||
task="pose",
|
|
||||||
data=data_yaml,
|
|
||||||
epochs=args.epochs,
|
|
||||||
imgsz=args.imgsz,
|
|
||||||
batch=args.batch,
|
|
||||||
name=args.name,
|
|
||||||
project=args.project,
|
|
||||||
exist_ok=True,
|
|
||||||
save=True,
|
|
||||||
save_period=5,
|
|
||||||
device=device,
|
|
||||||
workers=args.workers,
|
|
||||||
lr0=0.0001,
|
|
||||||
lrf=0.01,
|
|
||||||
optimizer="AdamW",
|
|
||||||
momentum=0.937,
|
|
||||||
weight_decay=0.001,
|
|
||||||
warmup_epochs=0,
|
|
||||||
warmup_momentum=0.8,
|
|
||||||
warmup_bias_lr=0.1,
|
|
||||||
hsv_h=0.015,
|
|
||||||
hsv_s=0.7,
|
|
||||||
hsv_v=0.4,
|
|
||||||
degrees=5.0,
|
|
||||||
translate=0.0,
|
|
||||||
scale=0.2,
|
|
||||||
shear=0.0,
|
|
||||||
perspective=0.0000,
|
|
||||||
flipud=0.0,
|
|
||||||
fliplr=0.5,
|
|
||||||
mosaic=0.0,
|
|
||||||
mixup=0.0,
|
|
||||||
copy_paste=0.0,
|
|
||||||
box=6,
|
|
||||||
cls=0.5,
|
|
||||||
dfl=1.5,
|
|
||||||
pose=18.0,
|
|
||||||
kobj=0.5,
|
|
||||||
freeze=0,
|
|
||||||
seed=42,
|
|
||||||
verbose=True,
|
|
||||||
amp=use_amp,
|
|
||||||
patience=100,
|
|
||||||
cos_lr=True,
|
|
||||||
)
|
|
||||||
|
|
||||||
print("\n✅ 训练完成!")
|
|
||||||
print(f"📁 best: {args.project}/{args.name}/weights/best.pt")
|
|
||||||
print(f"📁 last: {args.project}/{args.name}/weights/last.pt")
|
|
||||||
print("📊 仅看像素误差可运行: python pose_pixel_metrics.py --model <best.pt> --data <yaml> --imgsz", args.imgsz)
|
|
||||||
|
|
||||||
if args.export_onnx:
|
|
||||||
h, w = args.onnx_imgsz
|
|
||||||
print(f"📦 导出 ONNX imgsz=[{h}, {w}] ...")
|
|
||||||
model.export(format="onnx", imgsz=[h, w], simplify=True, opset=17, dynamic=False)
|
|
||||||
print("✅ ONNX 完成")
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
main()
|
|
||||||
+1
-23
@@ -4,28 +4,6 @@
|
|||||||
应用版本号
|
应用版本号
|
||||||
每次 OTA 更新时,只需要更新这个文件中的版本号
|
每次 OTA 更新时,只需要更新这个文件中的版本号
|
||||||
"""
|
"""
|
||||||
VERSION = '2.14.1'
|
VERSION = '2.15.35'
|
||||||
|
|
||||||
|
|
||||||
# 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登录部分
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -535,7 +535,7 @@ def detect_circle_v3(frame, laser_point=None, img_cv=None):
|
|||||||
logger.debug(f"[detect_circle_v3] begin {datetime.now()}")
|
logger.debug(f"[detect_circle_v3] begin {datetime.now()}")
|
||||||
# -- 1. 缩图加速(与三角形路径保持一致)
|
# -- 1. 缩图加速(与三角形路径保持一致)
|
||||||
h_orig, w_orig = img_cv.shape[:2]
|
h_orig, w_orig = img_cv.shape[:2]
|
||||||
MAX_DET_DIM = 320
|
MAX_DET_DIM = 480
|
||||||
long_side = max(h_orig, w_orig)
|
long_side = max(h_orig, w_orig)
|
||||||
if long_side > MAX_DET_DIM:
|
if long_side > MAX_DET_DIM:
|
||||||
det_scale = MAX_DET_DIM / long_side
|
det_scale = MAX_DET_DIM / long_side
|
||||||
@@ -570,20 +570,22 @@ def detect_circle_v3(frame, laser_point=None, img_cv=None):
|
|||||||
|
|
||||||
# -- 3. 红色掩码:在循环外只算一次
|
# -- 3. 红色掩码:在循环外只算一次
|
||||||
mask_red = cv2.bitwise_or(
|
mask_red = cv2.bitwise_or(
|
||||||
cv2.inRange(hsv, np.array([0, 80, 0]), np.array([10, 255, 255])),
|
cv2.inRange(hsv, np.array([0, 30, 20]), np.array([12, 255, 255])),
|
||||||
cv2.inRange(hsv, np.array([170, 80, 0]), np.array([180, 255, 255])),
|
cv2.inRange(hsv, np.array([168, 30, 20]), np.array([180, 255, 255])),
|
||||||
)
|
)
|
||||||
kernel_red = cv2.getStructuringElement(cv2.MORPH_ELLIPSE, (5, 5))
|
kernel_red = cv2.getStructuringElement(cv2.MORPH_ELLIPSE, (5, 5))
|
||||||
mask_red = cv2.morphologyEx(mask_red, cv2.MORPH_CLOSE, kernel_red)
|
mask_red = cv2.morphologyEx(mask_red, cv2.MORPH_CLOSE, kernel_red)
|
||||||
|
# 再加一次膨胀,加厚环状区域避免碎片化
|
||||||
|
mask_red = cv2.dilate(mask_red, kernel_red, iterations=1)
|
||||||
contours_red, _ = cv2.findContours(mask_red, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE)
|
contours_red, _ = cv2.findContours(mask_red, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE)
|
||||||
# 预先把红色轮廓筛选成 (center, radius) 列表,后续直接查表
|
# 预先把红色轮廓筛选成 (center, radius) 列表,后续直接查表
|
||||||
red_candidates = []
|
red_candidates = []
|
||||||
for cnt_r in contours_red:
|
for cnt_r in contours_red:
|
||||||
ar = cv2.contourArea(cnt_r)
|
ar = cv2.contourArea(cnt_r)
|
||||||
if ar <= 50:
|
if ar <= 10:
|
||||||
continue
|
continue
|
||||||
pr = cv2.arcLength(cnt_r, True)
|
pr = cv2.arcLength(cnt_r, True)
|
||||||
if pr <= 0 or (4 * np.pi * ar) / (pr * pr) <= 0.6:
|
if pr <= 0 or (4 * np.pi * ar) / (pr * pr) <= 0.2:
|
||||||
continue
|
continue
|
||||||
if len(cnt_r) >= 5:
|
if len(cnt_r) >= 5:
|
||||||
(xr, yr), (wr, hr), _ = cv2.fitEllipse(cnt_r)
|
(xr, yr), (wr, hr), _ = cv2.fitEllipse(cnt_r)
|
||||||
@@ -599,13 +601,13 @@ def detect_circle_v3(frame, laser_point=None, img_cv=None):
|
|||||||
valid_targets = []
|
valid_targets = []
|
||||||
for cnt_yellow in contours_yellow:
|
for cnt_yellow in contours_yellow:
|
||||||
area = cv2.contourArea(cnt_yellow)
|
area = cv2.contourArea(cnt_yellow)
|
||||||
if area <= 50:
|
if area <= 15:
|
||||||
continue
|
continue
|
||||||
perimeter = cv2.arcLength(cnt_yellow, True)
|
perimeter = cv2.arcLength(cnt_yellow, True)
|
||||||
if perimeter <= 0:
|
if perimeter <= 0:
|
||||||
continue
|
continue
|
||||||
circularity = (4 * np.pi * area) / (perimeter * perimeter)
|
circularity = (4 * np.pi * area) / (perimeter * perimeter)
|
||||||
if circularity <= 0.7:
|
if circularity <= 0.5:
|
||||||
continue
|
continue
|
||||||
if logger:
|
if logger:
|
||||||
logger.info(f"[target] -> 面积:{area:.1f}, 圆度:{circularity:.2f}")
|
logger.info(f"[target] -> 面积:{area:.1f}, 圆度:{circularity:.2f}")
|
||||||
@@ -625,7 +627,11 @@ def detect_circle_v3(frame, laser_point=None, img_cv=None):
|
|||||||
ddx = yellow_center[0] - rc["center"][0]
|
ddx = yellow_center[0] - rc["center"][0]
|
||||||
ddy = yellow_center[1] - rc["center"][1]
|
ddy = yellow_center[1] - rc["center"][1]
|
||||||
dist_centers = math.hypot(ddx, ddy)
|
dist_centers = math.hypot(ddx, ddy)
|
||||||
if dist_centers < yellow_radius * 1.5 and rc["radius"] > yellow_radius * 0.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.3:
|
||||||
if logger:
|
if logger:
|
||||||
logger.info(f"[target] -> 找到匹配的红圈: 黄心({yellow_center}), "
|
logger.info(f"[target] -> 找到匹配的红圈: 黄心({yellow_center}), "
|
||||||
f"红心({rc['center']}), 距离:{dist_centers:.1f}, "
|
f"红心({rc['center']}), 距离:{dist_centers:.1f}, "
|
||||||
@@ -638,8 +644,17 @@ def detect_circle_v3(frame, laser_point=None, img_cv=None):
|
|||||||
})
|
})
|
||||||
matched = True
|
matched = True
|
||||||
break
|
break
|
||||||
if not matched and logger:
|
if not matched:
|
||||||
logger.debug("Debug -> 未找到匹配的红色圆圈,可能是误识别")
|
# 黄圈高置信度兜底:大且圆时跳过红圈验证
|
||||||
|
if area > 30 and circularity > 0.8:
|
||||||
|
valid_targets.append({
|
||||||
|
"center": yellow_center,
|
||||||
|
"radius": yellow_radius,
|
||||||
|
"ellipse": yellow_ellipse,
|
||||||
|
"area": area,
|
||||||
|
})
|
||||||
|
elif logger:
|
||||||
|
logger.debug("Debug -> 未找到匹配的红色圆圈,可能是误识别")
|
||||||
|
|
||||||
logger.debug(f"[detect_circle_v3] step 4 fin {datetime.now()}")
|
logger.debug(f"[detect_circle_v3] step 4 fin {datetime.now()}")
|
||||||
|
|
||||||
|
|||||||
@@ -41,6 +41,7 @@ class WiFiManager:
|
|||||||
# WiFi 质量监测(后台线程)
|
# WiFi 质量监测(后台线程)
|
||||||
self._wifi_quality_monitor_thread = None
|
self._wifi_quality_monitor_thread = None
|
||||||
self._wifi_quality_stop_event = threading.Event()
|
self._wifi_quality_stop_event = threading.Event()
|
||||||
|
self._wifi_quality_lock = threading.Lock()
|
||||||
self._last_wifi_rtt_ms = None # 最近一次测量的 RTT
|
self._last_wifi_rtt_ms = None # 最近一次测量的 RTT
|
||||||
self._last_wifi_rssi_dbm = None # 最近一次测量的 RSSI
|
self._last_wifi_rssi_dbm = None # 最近一次测量的 RSSI
|
||||||
|
|
||||||
@@ -238,7 +239,6 @@ class WiFiManager:
|
|||||||
old_conf = _read_text(conf_path)
|
old_conf = _read_text(conf_path)
|
||||||
old_boot_ssid = _read_text(ssid_file)
|
old_boot_ssid = _read_text(ssid_file)
|
||||||
old_boot_pass = _read_text(pass_file)
|
old_boot_pass = _read_text(pass_file)
|
||||||
old_boot_wpa = _read_text(boot_wpa_path) if os.path.exists(boot_wpa_path) else None
|
|
||||||
|
|
||||||
try:
|
try:
|
||||||
try:
|
try:
|
||||||
@@ -250,9 +250,13 @@ class WiFiManager:
|
|||||||
_write_text(conf_path, full_conf)
|
_write_text(conf_path, full_conf)
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
_write_text(boot_wpa_path, full_conf)
|
# 删除 wpa_supplicant.conf,让 S30wifi 回退读 ssid/pass
|
||||||
|
try:
|
||||||
|
if os.path.exists(boot_wpa_path):
|
||||||
|
os.remove(boot_wpa_path)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
# 仍写入 ssid/pass,便于其它脚本/人工查看;S30wifi 优先使用 wpa_supplicant.conf
|
|
||||||
_write_text(ssid_file, ssid.strip())
|
_write_text(ssid_file, ssid.strip())
|
||||||
_write_text(pass_file, password.strip())
|
_write_text(pass_file, password.strip())
|
||||||
|
|
||||||
@@ -292,7 +296,6 @@ class WiFiManager:
|
|||||||
if not persist:
|
if not persist:
|
||||||
# 不持久化:把 /boot 恢复成旧值(不重启,当前连接保持不变)
|
# 不持久化:把 /boot 恢复成旧值(不重启,当前连接保持不变)
|
||||||
_restore_boot(old_boot_ssid, old_boot_pass)
|
_restore_boot(old_boot_ssid, old_boot_pass)
|
||||||
_restore_boot_wpa(old_boot_wpa)
|
|
||||||
self.logger.info("[WIFI] 网络验证通过,但按 persist=False 回滚 /boot 凭证(不重启)")
|
self.logger.info("[WIFI] 网络验证通过,但按 persist=False 回滚 /boot 凭证(不重启)")
|
||||||
else:
|
else:
|
||||||
self.logger.info("[WIFI] 网络验证通过,/boot 凭证已保留(持久化)")
|
self.logger.info("[WIFI] 网络验证通过,/boot 凭证已保留(持久化)")
|
||||||
@@ -306,7 +309,6 @@ class WiFiManager:
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
# 失败:回滚 /boot 和 /etc,重启 WiFi 恢复旧网络
|
# 失败:回滚 /boot 和 /etc,重启 WiFi 恢复旧网络
|
||||||
_restore_boot(old_boot_ssid, old_boot_pass)
|
_restore_boot(old_boot_ssid, old_boot_pass)
|
||||||
_restore_boot_wpa(old_boot_wpa)
|
|
||||||
try:
|
try:
|
||||||
if old_conf is not None:
|
if old_conf is not None:
|
||||||
_write_text(conf_path, old_conf)
|
_write_text(conf_path, old_conf)
|
||||||
@@ -351,7 +353,11 @@ class WiFiManager:
|
|||||||
else:
|
else:
|
||||||
full_conf = build_sta_conf_open(ssid)
|
full_conf = build_sta_conf_open(ssid)
|
||||||
_write_text(conf_path, full_conf)
|
_write_text(conf_path, full_conf)
|
||||||
_write_text(boot_wpa_path, full_conf)
|
try:
|
||||||
|
if os.path.exists(boot_wpa_path):
|
||||||
|
os.remove(boot_wpa_path)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
except ValueError as e:
|
except ValueError as e:
|
||||||
return False, str(e)
|
return False, str(e)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
@@ -535,73 +541,94 @@ class WiFiManager:
|
|||||||
|
|
||||||
def start_quality_monitor(self, network_type_callback, on_poor_quality_callback):
|
def start_quality_monitor(self, network_type_callback, on_poor_quality_callback):
|
||||||
"""
|
"""
|
||||||
启动 WiFi 质量后台监测线程(每 5 秒测量一次 RTT 和 RSSI)
|
启动 WiFi 质量后台监测线程(每 5 秒检查 STA 关联状态和 RSSI)
|
||||||
只在 WiFi 连接时运行,不影响业务发送性能
|
只在 WiFi 连接时运行,不影响业务发送性能
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
network_type_callback: 获取当前网络类型的回调函数
|
network_type_callback: 获取当前网络类型的回调函数
|
||||||
on_poor_quality_callback: WiFi质量差时的回调函数
|
on_poor_quality_callback: WiFi质量差时的回调函数
|
||||||
"""
|
"""
|
||||||
if self._wifi_quality_monitor_thread is not None:
|
with self._wifi_quality_lock:
|
||||||
self.logger.warning("[WiFi Monitor] 监测线程已在运行")
|
current_thread = self._wifi_quality_monitor_thread
|
||||||
return
|
current_stop_event = self._wifi_quality_stop_event
|
||||||
|
if (current_thread is not None and current_thread.is_alive()
|
||||||
self._network_type_callback = network_type_callback
|
and not current_stop_event.is_set()):
|
||||||
self._on_poor_quality_callback = on_poor_quality_callback
|
self.logger.warning("[WiFi Monitor] 监测线程已在运行")
|
||||||
self._wifi_quality_stop_event.clear()
|
return
|
||||||
self._wifi_quality_monitor_thread = threading.Thread(
|
|
||||||
target=self._quality_monitor_loop,
|
self._network_type_callback = network_type_callback
|
||||||
daemon=True,
|
self._on_poor_quality_callback = on_poor_quality_callback
|
||||||
name="wifi_quality_monitor"
|
stop_event = threading.Event()
|
||||||
)
|
self._wifi_quality_stop_event = stop_event
|
||||||
self._wifi_quality_monitor_thread.start()
|
self._wifi_quality_monitor_thread = threading.Thread(
|
||||||
self.logger.info("[WiFi Monitor] 已启动后台监测线程")
|
target=self._quality_monitor_loop,
|
||||||
|
args=(stop_event,),
|
||||||
|
daemon=True,
|
||||||
|
name="wifi_quality_monitor"
|
||||||
|
)
|
||||||
|
self._wifi_quality_monitor_thread.start()
|
||||||
|
self.logger.info("[WiFi Monitor] 已启动后台监测线程")
|
||||||
|
|
||||||
def stop_quality_monitor(self):
|
def stop_quality_monitor(self):
|
||||||
"""停止 WiFi 质量监测线程"""
|
"""停止 WiFi 质量监测线程"""
|
||||||
if self._wifi_quality_monitor_thread is None:
|
with self._wifi_quality_lock:
|
||||||
return
|
t = self._wifi_quality_monitor_thread
|
||||||
|
stop_event = self._wifi_quality_stop_event
|
||||||
|
if t is None:
|
||||||
|
return
|
||||||
|
if not t.is_alive():
|
||||||
|
self._wifi_quality_monitor_thread = None
|
||||||
|
return
|
||||||
|
|
||||||
self._wifi_quality_stop_event.set()
|
stop_event.set()
|
||||||
try:
|
try:
|
||||||
self._wifi_quality_monitor_thread.join(timeout=2.0)
|
t.join(timeout=2.0)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
self.logger.error(f"[WiFi Monitor] 停止线程失败:{e}")
|
self.logger.error(f"[WiFi Monitor] 停止线程失败:{e}")
|
||||||
finally:
|
|
||||||
self._wifi_quality_monitor_thread = None
|
with self._wifi_quality_lock:
|
||||||
self.logger.info("[WiFi Monitor] 已停止后台监测线程")
|
if t is self._wifi_quality_monitor_thread:
|
||||||
|
if t.is_alive():
|
||||||
|
self.logger.warning("[WiFi Monitor] 线程未在超时内退出,保留引用防止重复创建")
|
||||||
|
else:
|
||||||
|
self._wifi_quality_monitor_thread = None
|
||||||
|
self.logger.info("[WiFi Monitor] 已停止后台监测线程")
|
||||||
|
|
||||||
def _quality_monitor_loop(self):
|
def _quality_monitor_loop(self, stop_event):
|
||||||
"""
|
"""
|
||||||
WiFi 质量监测循环(后台线程)
|
WiFi 质量监测循环(后台线程)
|
||||||
每 5 秒测量一次 RTT 和 RSSI,发现质量差则触发切换
|
每 5 秒检查 STA 关联状态和 RSSI,发现断链或质量差则触发切换
|
||||||
"""
|
"""
|
||||||
while not self._wifi_quality_stop_event.is_set():
|
while not stop_event.is_set():
|
||||||
try:
|
try:
|
||||||
# 只在 WiFi 连接时才测量
|
# 只在 WiFi 连接时才测量
|
||||||
network_type = self._network_type_callback()
|
network_type = self._network_type_callback()
|
||||||
if network_type == "wifi" and self._wifi_socket:
|
if network_type == "wifi" and self._wifi_socket:
|
||||||
# # 测量 RTT(1 个样本,快速测量)
|
# RTT 测量当前禁用;STA 关联状态用于判断物理 WiFi 链路是否仍存在。
|
||||||
# rtt_ms, reachable = self._measure_wifi_tcp_rtt_ms(
|
# 不能把禁用的 RTT 伪装成 0ms,否则关闭热点后会一直被判为正常。
|
||||||
# self._server_ip, self._server_port,
|
reachable = self.is_sta_associated()
|
||||||
# samples=1, per_sample_timeout_ms=600
|
rtt_ms = None
|
||||||
# )
|
|
||||||
|
|
||||||
# 获取 RSSI
|
# 获取 RSSI
|
||||||
rssi_dbm = self._get_wifi_rssi_dbm()
|
rssi_dbm = self._get_wifi_rssi_dbm()
|
||||||
|
|
||||||
# 更新缓存
|
# 更新缓存
|
||||||
# 不使用 RTT 测量
|
self._last_wifi_rtt_ms = rtt_ms
|
||||||
rtt_ms = 0
|
|
||||||
reachable = True
|
|
||||||
self._last_wifi_rtt_ms = rtt_ms if reachable else None
|
|
||||||
self._last_wifi_rssi_dbm = rssi_dbm
|
self._last_wifi_rssi_dbm = rssi_dbm
|
||||||
|
_rtt_s = f"{rtt_ms:.0f}ms" if rtt_ms is not None else "n/a"
|
||||||
_rssi_s = f"{rssi_dbm:.0f}" if rssi_dbm is not None else "n/a"
|
_rssi_s = f"{rssi_dbm:.0f}" if rssi_dbm is not None else "n/a"
|
||||||
self.logger.debug(f"[WiFi Monitor] - RTT={rtt_ms:.0f}ms, RSSI={_rssi_s}dBm")
|
self.logger.debug(
|
||||||
|
f"[WiFi Monitor] - associated={reachable}, RTT={_rtt_s}, RSSI={_rssi_s}dBm"
|
||||||
|
)
|
||||||
|
|
||||||
# 判断质量是否差(切换前做 2 次快速复测,防止瞬时抖动)
|
# 判断质量是否差(切换前做 2 次快速复测,防止瞬时抖动)
|
||||||
def _is_bad_now(_reachable, _rtt, _rssi):
|
def _is_bad_now(_reachable, _rtt, _rssi):
|
||||||
if (not _reachable) or (_rtt is None) or (_rtt == float("inf")):
|
if not _reachable:
|
||||||
|
return True
|
||||||
|
# RTT 未启用时不参与质量判断;链路状态仍由 STA 关联保证。
|
||||||
|
if _rtt is None:
|
||||||
|
return False
|
||||||
|
if _rtt == float("inf"):
|
||||||
return True
|
return True
|
||||||
return self._is_wifi_quality_bad(_rtt, _rssi)
|
return self._is_wifi_quality_bad(_rtt, _rssi)
|
||||||
|
|
||||||
@@ -610,14 +637,10 @@ class WiFiManager:
|
|||||||
self.logger.warning("[WiFi Monitor] 质量差,切换前快速重试 2 次(每次间隔1秒)")
|
self.logger.warning("[WiFi Monitor] 质量差,切换前快速重试 2 次(每次间隔1秒)")
|
||||||
|
|
||||||
for retry_idx in range(2):
|
for retry_idx in range(2):
|
||||||
time.sleep_ms(1000)
|
if stop_event.wait(1.0):
|
||||||
# 不使用 RTT 测量
|
return
|
||||||
rtt2 = 0
|
reachable2 = self.is_sta_associated()
|
||||||
reachable2 = True
|
rtt2 = None
|
||||||
# rtt2, reachable2 = self._measure_wifi_tcp_rtt_ms(
|
|
||||||
# self._server_ip, self._server_port,
|
|
||||||
# samples=1, per_sample_timeout_ms=600
|
|
||||||
# )
|
|
||||||
rssi2 = self._get_wifi_rssi_dbm()
|
rssi2 = self._get_wifi_rssi_dbm()
|
||||||
|
|
||||||
# 更新缓存,便于外部查看最新状态
|
# 更新缓存,便于外部查看最新状态
|
||||||
@@ -626,14 +649,10 @@ class WiFiManager:
|
|||||||
|
|
||||||
bad2 = _is_bad_now(reachable2, rtt2, rssi2)
|
bad2 = _is_bad_now(reachable2, rtt2, rssi2)
|
||||||
try:
|
try:
|
||||||
_rtt_disp = (
|
_rtt_disp = f"{rtt2:.0f}ms" if rtt2 is not None else "n/a"
|
||||||
rtt2
|
|
||||||
if rtt2 is not None and rtt2 != float("inf")
|
|
||||||
else -1
|
|
||||||
)
|
|
||||||
self.logger.info(
|
self.logger.info(
|
||||||
f"[WiFi Monitor] 复测{retry_idx+1}/2: reachable={reachable2}, "
|
f"[WiFi Monitor] 复测{retry_idx+1}/2: reachable={reachable2}, "
|
||||||
f"rtt={_rtt_disp:.0f}ms, rssi={rssi2}, bad={bad2}"
|
f"rtt={_rtt_disp}, rssi={rssi2}, bad={bad2}"
|
||||||
)
|
)
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
@@ -648,7 +667,7 @@ class WiFiManager:
|
|||||||
self._on_poor_quality_callback()
|
self._on_poor_quality_callback()
|
||||||
|
|
||||||
# 休眠 5 秒
|
# 休眠 5 秒
|
||||||
time.sleep(5)
|
stop_event.wait(5.0)
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
self.logger.error(f"[WiFi Monitor] 监测异常:{e}")
|
self.logger.error(f"[WiFi Monitor] 监测异常:{e}")
|
||||||
|
|||||||
Reference in New Issue
Block a user