Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9d3826047e | ||
|
|
64722f4d73 | ||
|
|
575e690868 | ||
|
|
46508e4b31 |
@@ -1 +0,0 @@
|
|||||||
*.sh text eol=lf
|
|
||||||
@@ -1,4 +1,3 @@
|
|||||||
/cpp_ext/build/
|
/cpp_ext/build/
|
||||||
/.cursor/
|
/.cursor/
|
||||||
/dist/
|
/dist/
|
||||||
.idea
|
|
||||||
Generated
+1
@@ -0,0 +1 @@
|
|||||||
|
network.py
|
||||||
Generated
+1
-6
@@ -1,10 +1,5 @@
|
|||||||
<?xml version="1.0" encoding="UTF-8"?>
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
<module type="PYTHON_MODULE" version="4">
|
<module version="4">
|
||||||
<component name="NewModuleRootManager">
|
|
||||||
<content url="file://$MODULE_DIR$" />
|
|
||||||
<orderEntry type="jdk" jdkName="yolov8" jdkType="Python SDK" />
|
|
||||||
<orderEntry type="sourceFolder" forTests="false" />
|
|
||||||
</component>
|
|
||||||
<component name="PyDocumentationSettings">
|
<component name="PyDocumentationSettings">
|
||||||
<option name="format" value="PLAIN" />
|
<option name="format" value="PLAIN" />
|
||||||
<option name="myDocStringFormat" value="Plain" />
|
<option name="myDocStringFormat" value="Plain" />
|
||||||
|
|||||||
Generated
+2
-2
@@ -1,7 +1,7 @@
|
|||||||
<?xml version="1.0" encoding="UTF-8"?>
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
<project version="4">
|
<project version="4">
|
||||||
<component name="Black">
|
<component name="Black">
|
||||||
<option name="sdkName" value="yolov8" />
|
<option name="sdkName" value="Python 3.13 virtualenv at H:\iot\racingiot_v1\.venv" />
|
||||||
</component>
|
</component>
|
||||||
<component name="ProjectRootManager" version="2" project-jdk-name="yolov8" project-jdk-type="Python SDK" />
|
<component name="ProjectRootManager" version="2" project-jdk-name="maixcam" project-jdk-type="Python SDK" />
|
||||||
</project>
|
</project>
|
||||||
Generated
-8
@@ -1,8 +0,0 @@
|
|||||||
<?xml version="1.0" encoding="UTF-8"?>
|
|
||||||
<project version="4">
|
|
||||||
<component name="ProjectModuleManager">
|
|
||||||
<modules>
|
|
||||||
<module fileurl="file://$PROJECT_DIR$/.idea/archery.iml" filepath="$PROJECT_DIR$/.idea/archery.iml" />
|
|
||||||
</modules>
|
|
||||||
</component>
|
|
||||||
</project>
|
|
||||||
Vendored
-3
@@ -1,3 +0,0 @@
|
|||||||
{
|
|
||||||
"cmake.sourceDirectory": "E:/code/code/code/new/new/new/archery/cpp_ext"
|
|
||||||
}
|
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
v1.2.15.1] [ERROR] main.py:416 - [MAIN] 显示异常: 'LaserManager' object has no attribute 'remote_detect_tick'
|
||||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -4,12 +4,12 @@ from maix import time
|
|||||||
a = adc.ADC(0, adc.RES_BIT_12)
|
a = adc.ADC(0, adc.RES_BIT_12)
|
||||||
|
|
||||||
while True:
|
while True:
|
||||||
# raw_data = a.read()
|
raw_data = a.read()
|
||||||
# print(f"ADC raw data:{raw_data}")
|
print(f"ADC raw data:{raw_data}")
|
||||||
# if raw_data > 2450:
|
if raw_data > 2450:
|
||||||
# print(f"ADC raw data:{raw_data}")
|
print(f"ADC raw data:{raw_data}")
|
||||||
# elif raw_data < 2000:
|
elif raw_data < 2000:
|
||||||
# print(f"ADC raw data:{raw_data}")
|
print(f"ADC raw data:{raw_data}")
|
||||||
time.sleep_ms(1)
|
time.sleep_ms(1)
|
||||||
|
|
||||||
vol = int(a.read_vol() * 10) / 10
|
vol = int(a.read_vol() * 10) / 10
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
id: t11
|
id: t11
|
||||||
name: t11
|
name: t11
|
||||||
version: 2.15.31
|
version: 2.1.1
|
||||||
author: t11
|
author: t11
|
||||||
icon: ''
|
icon: ''
|
||||||
desc: t11
|
desc: t11
|
||||||
@@ -12,18 +12,18 @@ 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
|
||||||
|
- set_autostart.py
|
||||||
- shoot_manager.py
|
- shoot_manager.py
|
||||||
- shot_id_generator.py
|
- shot_id_generator.py
|
||||||
- target_roi_yolo.py
|
- target_roi_yolo.py
|
||||||
|
|||||||
+6
-7
@@ -76,11 +76,10 @@ class ATClient:
|
|||||||
"""
|
"""
|
||||||
expect_b = expect.encode() if isinstance(expect, str) else expect
|
expect_b = expect.encode() if isinstance(expect, str) else expect
|
||||||
with self._cmd_lock:
|
with self._cmd_lock:
|
||||||
with self._q_lock:
|
# 初始化等待
|
||||||
# 初始化等待
|
self._waiting = True
|
||||||
self._waiting = True
|
self._expect = expect_b
|
||||||
self._expect = expect_b
|
self._resp = b""
|
||||||
self._resp = b""
|
|
||||||
|
|
||||||
# 发送
|
# 发送
|
||||||
if cmd:
|
if cmd:
|
||||||
@@ -301,8 +300,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) > 32768:
|
if len(self._rx) > 16384:
|
||||||
self._rx = self._rx[-16384:]
|
self._rx = self._rx[-4096:]
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -1,47 +0,0 @@
|
|||||||
#!/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 = 5 # 心跳间隔(秒)
|
HEARTBEAT_INTERVAL = 15 # 心跳间隔(秒)
|
||||||
|
|
||||||
# WiFi 质量评估(开机先尝试 WiFi;质量差且 4G 可用则切到 4G,本次上电直至关机锁定 4G)
|
# WiFi 质量评估(开机先尝试 WiFi;质量差且 4G 可用则切到 4G,本次上电直至关机锁定 4G)
|
||||||
WIFI_QUALITY_RTT_SAMPLES = 3 # 到业务服务器 TCP 建连耗时采样次数,取中位数
|
WIFI_QUALITY_RTT_SAMPLES = 3 # 到业务服务器 TCP 建连耗时采样次数,取中位数
|
||||||
@@ -106,6 +106,14 @@ DEFAULT_LASER_POINT = (320, 245) # 默认激光中心点
|
|||||||
HARDCODE_LASER_POINT = True # 是否使用硬编码的激光点(True=使用硬编码值,False=使用校准值)
|
HARDCODE_LASER_POINT = True # 是否使用硬编码的激光点(True=使用硬编码值,False=使用校准值)
|
||||||
HARDCODE_LASER_POINT_VALUE = (320, 296) # 硬编码的激光点坐标(315, 245) # # 硬编码的激光点坐标 (x, y)
|
HARDCODE_LASER_POINT_VALUE = (320, 296) # 硬编码的激光点坐标(315, 245) # # 硬编码的激光点坐标 (x, y)
|
||||||
|
|
||||||
|
# 远程激光点识别(TCP cmd=200):画面内找红点,稳定 N 秒且无明显跳动后上报坐标
|
||||||
|
LASER_REMOTE_DETECT_STABLE_SEC = 3.0 # 连续稳定时长(秒)
|
||||||
|
LASER_REMOTE_DETECT_MAX_MOVE_PX = 12.0 # 窗口内最大位移超过此值视为大幅移动,重新计时
|
||||||
|
LASER_REMOTE_DETECT_SAMPLE_MS = 80 # 采样间隔
|
||||||
|
LASER_REMOTE_DETECT_MIN_SAMPLES = 8 # 判定稳定前窗口内最少样本数
|
||||||
|
LASER_REMOTE_DETECT_WARMUP_MS = 500 # cmd=200 开激光后等待稳定再采样
|
||||||
|
# 远程识别会话无总超时:cmd=200 启动后持续检测并上报,直至 cmd=201 停止
|
||||||
|
|
||||||
# 激光点检测配置
|
# 激光点检测配置
|
||||||
LASER_DETECTION_THRESHOLD = 140 # 红色通道阈值(默认120,可调整,范围建议:100-150)
|
LASER_DETECTION_THRESHOLD = 140 # 红色通道阈值(默认120,可调整,范围建议:100-150)
|
||||||
LASER_RED_RATIO = 1.5 # 红色相对于绿色/蓝色的倍数要求(默认1.5,可调整,范围建议:1.3-2.0)
|
LASER_RED_RATIO = 1.5 # 红色相对于绿色/蓝色的倍数要求(默认1.5,可调整,范围建议:1.3-2.0)
|
||||||
@@ -134,7 +142,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 = False # False 时仅走黄心圆/椭圆 + 半径估距,不使用三角形路径
|
USE_TRIANGLE_OFFSET = True # 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"
|
||||||
# 检测到的三角形边长在图像中的像素范围,分辨率或靶纸占比变化时可微调
|
# 检测到的三角形边长在图像中的像素范围,分辨率或靶纸占比变化时可微调
|
||||||
@@ -144,6 +152,13 @@ TRIANGLE_SIZE_RANGE = (8, 500)
|
|||||||
# 如果射箭距离很固定,可设具体范围(如 min=2.5, max=6.0)作为额外保险
|
# 如果射箭距离很固定,可设具体范围(如 min=2.5, max=6.0)作为额外保险
|
||||||
TRIANGLE_DISTANCE_MIN_M = 0.0 # 0=不启用下限检查
|
TRIANGLE_DISTANCE_MIN_M = 0.0 # 0=不启用下限检查
|
||||||
TRIANGLE_DISTANCE_MAX_M = 0.0 # 0=不启用上限检查
|
TRIANGLE_DISTANCE_MAX_M = 0.0 # 0=不启用上限检查
|
||||||
|
# 三角形方向校验:四角黑三角应为 ◤ ◥ / ◣ ◢,即三角形从外角指向靶心;用于过滤相邻靶混入/跨靶组合
|
||||||
|
TRIANGLE_DIRECTION_VALIDATE_ENABLE = False
|
||||||
|
TRIANGLE_DIRECTION_MIN_PASS = 3 # 至少多少个真实三角方向正确才认为该组有效;3点补全时推荐3,误检多可设2
|
||||||
|
TRIANGLE_DIRECTION_DOT_MIN = 0.0 # 方向点积阈值;0=只要求同向半平面,0.35≈夹角<70°,0.5≈夹角<60°
|
||||||
|
TRIANGLE_DIRECTION_TO_CENTER_DOT_MIN = 0.35 # 必须指向候选靶心;0.35≈夹角<70°,用于过滤相邻靶混入
|
||||||
|
TRIANGLE_CENTER_DISTANCE_VALIDATE_ENABLE = True # 四角三角到候选靶心距离需近似一致,过滤跨靶组合
|
||||||
|
TRIANGLE_CENTER_DISTANCE_RATIO_TOL = 0.45 # (max_dist-min_dist)/mean_dist 最大允许值;越小越严格
|
||||||
# 三角形检测兜底增强:CLAHE(更鲁棒但更慢)。颜色阈值修复后通常不需要,保持关闭以优先速度。
|
# 三角形检测兜底增强:CLAHE(更鲁棒但更慢)。颜色阈值修复后通常不需要,保持关闭以优先速度。
|
||||||
TRIANGLE_ENABLE_CLAHE_FALLBACK = False
|
TRIANGLE_ENABLE_CLAHE_FALLBACK = False
|
||||||
# 三角形检测调试:保存 Otsu 二值化图像(临时调试用,定位后关闭)
|
# 三角形检测调试:保存 Otsu 二值化图像(临时调试用,定位后关闭)
|
||||||
@@ -169,6 +184,7 @@ TRIANGLE_SHAPE_COS_TOLERANCE = 0.25 # 直角余弦绝对值上限(原 0.20
|
|||||||
# 建议设为实测最坏耗时的 1.2 倍;超时后圆心检测仍会并行跑完,跑完后若三角形已结束则优先用三角形。
|
# 建议设为实测最坏耗时的 1.2 倍;超时后圆心检测仍会并行跑完,跑完后若三角形已结束则优先用三角形。
|
||||||
TRIANGLE_TIMEOUT_MS = 1000
|
TRIANGLE_TIMEOUT_MS = 1000
|
||||||
# True=打印各阶段耗时(ms),用于定位瓶颈;稳定后可 False 减少日志
|
# True=打印各阶段耗时(ms),用于定位瓶颈;稳定后可 False 减少日志
|
||||||
|
ARCHERY_TIMING_ENABLE = False # 总开关:False 关闭所有算法耗时统计(shoot_manager + triangle_target + vision)
|
||||||
TRIANGLE_TIMING_LOG = True
|
TRIANGLE_TIMING_LOG = True
|
||||||
# True=Stage2 每个子框内传统三角失败时打一条统计(Otsu/Adaptive 下轮廓数与各拒绝原因计数)
|
# True=Stage2 每个子框内传统三角失败时打一条统计(Otsu/Adaptive 下轮廓数与各拒绝原因计数)
|
||||||
TRIANGLE_LOG_STAGE2_PATCH_REJECT = True
|
TRIANGLE_LOG_STAGE2_PATCH_REJECT = True
|
||||||
@@ -256,11 +272,15 @@ TRIANGLE_CROP_ROI_MIN_SIDE_PX = 64
|
|||||||
# 射箭保存图 / 预览上绘制 YOLO 靶环 ROI 矩形 (x0,y0,x1,y1),核对是否裁准;不需要时改 False
|
# 射箭保存图 / 预览上绘制 YOLO 靶环 ROI 矩形 (x0,y0,x1,y1),核对是否裁准;不需要时改 False
|
||||||
TRIANGLE_YOLO_DRAW_ROI_ON_SHOT = True
|
TRIANGLE_YOLO_DRAW_ROI_ON_SHOT = True
|
||||||
# 物方采样调试:以靶心为中心,取半径 15cm 的圆周样本点,用于黑/白颜色对比
|
# 物方采样调试:以靶心为中心,取半径 15cm 的圆周样本点,用于黑/白颜色对比
|
||||||
|
TRIANGLE_SAMPLE_ENABLE = True
|
||||||
|
TRIANGLE_SAMPLE_TIMING_ENABLE = True # 仅统计物方采样耗时(其他 timing 可关)
|
||||||
TRIANGLE_SAMPLE_RADIUS_CM = 15.0
|
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
|
||||||
|
# 物方采样判断黑白阈值(R/G/B 均小于此值视为黑);40cm 黑靶在靶面位置全黑,20cm 白靶则 R/G/B 偏高
|
||||||
|
TRIANGLE_SAMPLE_BLACK_THRESH = 30.0
|
||||||
# 开机阶段预加载 YOLO detector;detect 使用 dual_buff=False,避免返回上一帧结果。
|
# 开机阶段预加载 YOLO detector;detect 使用 dual_buff=False,避免返回上一帧结果。
|
||||||
TRIANGLE_YOLO_PRELOAD_ON_BOOT = False
|
TRIANGLE_YOLO_PRELOAD_ON_BOOT = True
|
||||||
|
|
||||||
# ── 第二段 YOLO:仅在 Stage1 裁切出的靶环图上推理(与合成 stage2 训练数据一致)→ 子框内传统算法取直角点 ──
|
# ── 第二段 YOLO:仅在 Stage1 裁切出的靶环图上推理(与合成 stage2 训练数据一致)→ 子框内传统算法取直角点 ──
|
||||||
# Stage1 靶环裁切内如何找黑三角标记(对比耗时时可切换):
|
# Stage1 靶环裁切内如何找黑三角标记(对比耗时时可切换):
|
||||||
@@ -308,21 +328,16 @@ LASER_COLOR = (0, 255, 0) # RGB颜色
|
|||||||
LASER_THICKNESS = 1
|
LASER_THICKNESS = 1
|
||||||
LASER_LENGTH = 2
|
LASER_LENGTH = 2
|
||||||
|
|
||||||
# ==================== 队列大小限制(防止内存泄漏) ====================
|
|
||||||
MAX_SEND_QUEUE_SIZE = 500 # 发送队列上限
|
|
||||||
MAX_TCP_PAYLOADS = 500 # AT TCP 载荷缓存上限
|
|
||||||
MAX_HTTP_EVENTS = 200 # AT HTTP 事件缓存上限
|
|
||||||
LOG_QUEUE_MAXSIZE = 10000 # 日志队列上限
|
|
||||||
MAX_CMD_THREADS = 10 # 并发命令线程上限(防止服务器下发命令时无限创建线程)
|
|
||||||
|
|
||||||
# ==================== 图像保存配置 ====================
|
# ==================== 图像保存配置 ====================
|
||||||
SAVE_IMAGE_ENABLED = False # 是否保存图像(True=保存,False=不保存)
|
SAVE_IMAGE_ENABLED = True # 是否保存图像(True=保存,False=不保存)
|
||||||
|
SAVE_RAW_SHOT_IMAGE_ENABLED = False # 是否额外保存射箭原图;可通过 TCP cmd=46 动态开关
|
||||||
|
VISION_TIMING_ENABLE = True # 视觉圆检测耗时统计(detect_circle_v3 内部各步骤耗时)
|
||||||
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 相同
|
||||||
TRIANGLE_BLACK_YOLO_STAGE2_ROI_MAX_IMAGES = None
|
TRIANGLE_BLACK_YOLO_STAGE2_ROI_MAX_IMAGES = None
|
||||||
|
|
||||||
SHOW_CAMERA_PHOTO_WHILE_SHOOTING = False # 是否在拍摄时显示摄像头图像(True=显示,False=不显示),建议在连着USB测试过程中打开
|
SHOW_CAMERA_PHOTO_WHILE_SHOOTING = True # 是否在拍摄时显示摄像头图像(True=显示,False=不显示),建议在连着USB测试过程中打开
|
||||||
|
|
||||||
# ==================== OTA配置 ====================
|
# ==================== OTA配置 ====================
|
||||||
MAX_BACKUPS = 5
|
MAX_BACKUPS = 5
|
||||||
@@ -343,15 +358,6 @@ 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 = 5000
|
|
||||||
CHARGING_CURRENT_THRESHOLD_MA = 100.0
|
|
||||||
CHARGING_CONFIRM_COUNT = 2
|
|
||||||
CHARGING_NOTIFY_TIMEOUT_MS = 30000
|
|
||||||
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,248 +0,0 @@
|
|||||||
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
|
|
||||||
+422
-41
@@ -6,6 +6,7 @@
|
|||||||
"""
|
"""
|
||||||
import _thread
|
import _thread
|
||||||
import json
|
import json
|
||||||
|
import math
|
||||||
import os
|
import os
|
||||||
import binascii
|
import binascii
|
||||||
from maix import time
|
from maix import time
|
||||||
@@ -34,9 +35,13 @@ class LaserManager:
|
|||||||
self._calibration_active = False
|
self._calibration_active = False
|
||||||
self._calibration_result = None
|
self._calibration_result = None
|
||||||
self._calibration_lock = threading.Lock()
|
self._calibration_lock = threading.Lock()
|
||||||
|
self._remote_detect_active = False
|
||||||
|
self._remote_detect_lock = threading.Lock()
|
||||||
|
self._remote_detect_result = None
|
||||||
self._laser_point = None
|
self._laser_point = None
|
||||||
self._laser_turned_on = False
|
self._laser_turned_on = False
|
||||||
self._last_frame_with_ellipse = None # 保存绘制了椭圆的图像(用于调试/显示)
|
self._last_frame_with_ellipse = None # 保存绘制了椭圆的图像(用于调试/显示)
|
||||||
|
self._remote_detect_last_pos = None
|
||||||
self._initialized = True
|
self._initialized = True
|
||||||
|
|
||||||
# ==================== 状态访问(只读属性)====================
|
# ==================== 状态访问(只读属性)====================
|
||||||
@@ -54,8 +59,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,30 +107,262 @@ class LaserManager:
|
|||||||
# ==================== 业务方法 ====================
|
# ==================== 业务方法 ====================
|
||||||
|
|
||||||
def load_laser_point(self):
|
def load_laser_point(self):
|
||||||
"""加载激光中心点:优先使用本地保存的坐标,其次硬编码值,最后默认值"""
|
"""从配置文件加载激光中心点,失败则使用默认值
|
||||||
# 优先:从本地持久化文件加载(由 cmd 201 保存)
|
如果启用硬编码模式,则直接使用硬编码值
|
||||||
try:
|
"""
|
||||||
if "laser_config.json" in os.listdir("/root"):
|
|
||||||
with open(config.CONFIG_FILE, "r") as f:
|
|
||||||
data = json.load(f)
|
|
||||||
if isinstance(data, list) and len(data) == 2:
|
|
||||||
self._laser_point = (int(data[0]), int(data[1]))
|
|
||||||
self.logger.info(f"[LASER] 从本地加载激光点: {self._laser_point}")
|
|
||||||
return self._laser_point
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
|
|
||||||
# 其次:硬编码值
|
|
||||||
if config.HARDCODE_LASER_POINT:
|
if config.HARDCODE_LASER_POINT:
|
||||||
|
# 硬编码模式:直接使用硬编码值
|
||||||
self._laser_point = config.HARDCODE_LASER_POINT_VALUE
|
self._laser_point = config.HARDCODE_LASER_POINT_VALUE
|
||||||
self.logger.info(f"[LASER] 使用硬编码激光点: {self._laser_point}")
|
self.logger.info(f"[LASER] 使用硬编码激光点: {self._laser_point}")
|
||||||
return self._laser_point
|
return self._laser_point
|
||||||
|
|
||||||
# 最后:默认值
|
# 正常模式:从配置文件加载
|
||||||
self._laser_point = config.DEFAULT_LASER_POINT
|
try:
|
||||||
self.logger.info(f"[LASER] 使用默认激光点: {self._laser_point}")
|
if os.path.exists(config.CONFIG_FILE):
|
||||||
|
with open(config.CONFIG_FILE, "r") as f:
|
||||||
|
data = json.load(f)
|
||||||
|
if isinstance(data, list) and len(data) == 2:
|
||||||
|
self._laser_point = (int(data[0]), int(data[1]))
|
||||||
|
self.logger.debug(f"[INFO] 加载激光点: {self._laser_point}")
|
||||||
|
return self._laser_point
|
||||||
|
else:
|
||||||
|
raise ValueError
|
||||||
|
else:
|
||||||
|
self._laser_point = config.DEFAULT_LASER_POINT
|
||||||
|
except Exception as e:
|
||||||
|
if self.logger:
|
||||||
|
self.logger.warning(f"[LASER] 加载激光点失败,使用默认值: {e}")
|
||||||
|
self._laser_point = config.DEFAULT_LASER_POINT
|
||||||
|
|
||||||
return self._laser_point
|
return self._laser_point
|
||||||
|
|
||||||
|
@property
|
||||||
|
def remote_detect_active(self):
|
||||||
|
with self._remote_detect_lock:
|
||||||
|
return self._remote_detect_active
|
||||||
|
|
||||||
|
def get_remote_detect_result(self):
|
||||||
|
"""获取并清除远程激光识别结果 (x, y) 或 None。"""
|
||||||
|
with self._remote_detect_lock:
|
||||||
|
result = self._remote_detect_result
|
||||||
|
self._remote_detect_result = None
|
||||||
|
return result
|
||||||
|
|
||||||
|
def remote_detect_tick(self, frame):
|
||||||
|
"""
|
||||||
|
主循环显示路径调用的轻量 tick。
|
||||||
|
兼容旧调用点:当前远程识别由后台线程处理,这里不做重计算,
|
||||||
|
仅保留接口避免 AttributeError。
|
||||||
|
"""
|
||||||
|
return None
|
||||||
|
|
||||||
|
def overlay_remote_detect_preview(self, frame):
|
||||||
|
"""
|
||||||
|
在预览画面叠加远程识别点与坐标文本。
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
import cv2
|
||||||
|
from maix import image
|
||||||
|
with self._remote_detect_lock:
|
||||||
|
pos = self._remote_detect_last_pos
|
||||||
|
if not pos:
|
||||||
|
return frame
|
||||||
|
|
||||||
|
img_cv = image.image2cv(frame, False, False)
|
||||||
|
if img_cv is None or img_cv.size == 0:
|
||||||
|
return frame
|
||||||
|
|
||||||
|
x, y = int(pos[0]), int(pos[1])
|
||||||
|
h, w = img_cv.shape[:2]
|
||||||
|
if x < 0 or y < 0 or x >= w or y >= h:
|
||||||
|
return frame
|
||||||
|
|
||||||
|
color = (255, 0, 0) # RGB
|
||||||
|
cv2.circle(img_cv, (x, y), 8, color, 2)
|
||||||
|
cv2.line(img_cv, (x - 12, y), (x + 12, y), color, 1)
|
||||||
|
cv2.line(img_cv, (x, y - 12), (x, y + 12), color, 1)
|
||||||
|
cv2.putText(img_cv, f"laser=({x},{y})", (max(5, x + 10), max(20, y - 10)),
|
||||||
|
cv2.FONT_HERSHEY_SIMPLEX, 0.55, color, 1, cv2.LINE_AA)
|
||||||
|
|
||||||
|
return image.cv2image(img_cv, False, False)
|
||||||
|
except Exception as e:
|
||||||
|
if self.logger:
|
||||||
|
self.logger.debug(f"[LASER-REMOTE] overlay 绘制失败: {e}")
|
||||||
|
return frame
|
||||||
|
|
||||||
|
def _set_remote_detect_result(self, result):
|
||||||
|
with self._remote_detect_lock:
|
||||||
|
self._remote_detect_result = result
|
||||||
|
|
||||||
|
def set_hardcoded_laser_point(self, x, y):
|
||||||
|
"""更新 config.HARDCODE_LASER_POINT_VALUE(TCP cmd=201)。"""
|
||||||
|
try:
|
||||||
|
ix = int(round(float(x)))
|
||||||
|
iy = int(round(float(y)))
|
||||||
|
except (TypeError, ValueError) as e:
|
||||||
|
raise ValueError(f"invalid laser point ({x!r}, {y!r})") from e
|
||||||
|
config.HARDCODE_LASER_POINT = True
|
||||||
|
config.HARDCODE_LASER_POINT_VALUE = (ix, iy)
|
||||||
|
self._laser_point = (ix, iy)
|
||||||
|
try:
|
||||||
|
with open(config.CONFIG_FILE, "w") as f:
|
||||||
|
json.dump([ix, iy], f)
|
||||||
|
except Exception as e:
|
||||||
|
if self.logger:
|
||||||
|
self.logger.warning(f"[LASER] 保存硬编码激光点到本地失败: {e}")
|
||||||
|
raise
|
||||||
|
if self.logger:
|
||||||
|
self.logger.info(
|
||||||
|
f"[LASER] 已设置硬编码激光点 HARDCODE_LASER_POINT_VALUE=({ix}, {iy}) 并已保存到 {config.CONFIG_FILE}"
|
||||||
|
)
|
||||||
|
return ix, iy
|
||||||
|
|
||||||
|
def start_remote_laser_detect(self):
|
||||||
|
"""
|
||||||
|
启动远程激光识别会话(TCP cmd=200):开激光后持续检测。
|
||||||
|
每次稳定 3s 上报一次坐标,外循环直到 cmd=201 调用 stop_remote_laser_detect()。
|
||||||
|
Returns:
|
||||||
|
True 已启动;False 会话已在运行
|
||||||
|
"""
|
||||||
|
with self._remote_detect_lock:
|
||||||
|
if self._remote_detect_active:
|
||||||
|
return False
|
||||||
|
self._remote_detect_active = True
|
||||||
|
self._remote_detect_result = None
|
||||||
|
self._remote_detect_last_pos = None
|
||||||
|
_thread.start_new_thread(self._remote_laser_detect_worker, ())
|
||||||
|
if self.logger:
|
||||||
|
self.logger.info("[LASER] 远程激光识别已启动 (cmd=200)")
|
||||||
|
return True
|
||||||
|
|
||||||
|
def stop_remote_laser_detect(self):
|
||||||
|
with self._remote_detect_lock:
|
||||||
|
self._remote_detect_active = False
|
||||||
|
|
||||||
|
def _remote_laser_detect_worker(self):
|
||||||
|
from camera_manager import camera_manager
|
||||||
|
|
||||||
|
stable_sec = float(getattr(config, "LASER_REMOTE_DETECT_STABLE_SEC", 3.0))
|
||||||
|
max_move = float(getattr(config, "LASER_REMOTE_DETECT_MAX_MOVE_PX", 12.0))
|
||||||
|
sample_ms = int(getattr(config, "LASER_REMOTE_DETECT_SAMPLE_MS", 80))
|
||||||
|
min_samples = int(getattr(config, "LASER_REMOTE_DETECT_MIN_SAMPLES", 8))
|
||||||
|
warmup_ms = int(getattr(config, "LASER_REMOTE_DETECT_WARMUP_MS", 500))
|
||||||
|
stable_ms = int(max(500, stable_sec * 1000))
|
||||||
|
|
||||||
|
samples = []
|
||||||
|
miss_count = 0
|
||||||
|
stable_hit_count = 0
|
||||||
|
reported = False
|
||||||
|
|
||||||
|
try:
|
||||||
|
if not self._laser_turned_on:
|
||||||
|
try:
|
||||||
|
self.turn_on_laser()
|
||||||
|
except Exception as e:
|
||||||
|
if self.logger:
|
||||||
|
self.logger.warning(f"[LASER] cmd200 worker 开激光失败: {e}")
|
||||||
|
if warmup_ms > 0:
|
||||||
|
if self.logger:
|
||||||
|
self.logger.info(f"[LASER] cmd200 激光预热 {warmup_ms}ms …")
|
||||||
|
time.sleep_ms(warmup_ms)
|
||||||
|
|
||||||
|
if self.logger:
|
||||||
|
self.logger.info("[LASER] 远程识别外循环已启动,直至 cmd=201 停止")
|
||||||
|
|
||||||
|
while True:
|
||||||
|
with self._remote_detect_lock:
|
||||||
|
if not self._remote_detect_active:
|
||||||
|
if self.logger:
|
||||||
|
self.logger.info("[LASER] 远程识别会话结束 (cmd=201 或取消)")
|
||||||
|
return
|
||||||
|
|
||||||
|
try:
|
||||||
|
frame = camera_manager.read_frame()
|
||||||
|
pos = self.find_red_laser_remote(frame)
|
||||||
|
except Exception as e:
|
||||||
|
if self.logger:
|
||||||
|
self.logger.warning(f"[LASER] 远程识别帧异常: {e}")
|
||||||
|
pos = None
|
||||||
|
time.sleep_ms(sample_ms)
|
||||||
|
continue
|
||||||
|
|
||||||
|
now_ms = time.ticks_ms()
|
||||||
|
if pos is None:
|
||||||
|
miss_count += 1
|
||||||
|
samples.clear()
|
||||||
|
stable_hit_count = 0
|
||||||
|
if miss_count == 1 or miss_count % 40 == 0:
|
||||||
|
if self.logger:
|
||||||
|
self.logger.info(
|
||||||
|
f"[LASER-REMOTE] 本帧未检出激光点(累计 {miss_count} 帧),"
|
||||||
|
f"全图多策略搜索中…"
|
||||||
|
)
|
||||||
|
time.sleep_ms(sample_ms)
|
||||||
|
continue
|
||||||
|
|
||||||
|
miss_count = 0
|
||||||
|
x, y = float(pos[0]), float(pos[1])
|
||||||
|
samples.append((now_ms, x, y))
|
||||||
|
cutoff = now_ms - stable_ms
|
||||||
|
samples = [(t, px, py) for t, px, py in samples if t >= cutoff]
|
||||||
|
|
||||||
|
if len(samples) < 2:
|
||||||
|
time.sleep_ms(sample_ms)
|
||||||
|
continue
|
||||||
|
|
||||||
|
xs = [s[1] for s in samples]
|
||||||
|
ys = [s[2] for s in samples]
|
||||||
|
span = max(
|
||||||
|
max(xs) - min(xs),
|
||||||
|
max(ys) - min(ys),
|
||||||
|
)
|
||||||
|
for i in range(len(samples)):
|
||||||
|
for j in range(i + 1, len(samples)):
|
||||||
|
d = math.hypot(
|
||||||
|
samples[i][1] - samples[j][1],
|
||||||
|
samples[i][2] - samples[j][2],
|
||||||
|
)
|
||||||
|
span = max(span, d)
|
||||||
|
|
||||||
|
if span > max_move:
|
||||||
|
if self.logger:
|
||||||
|
self.logger.debug(
|
||||||
|
f"[LASER] 检测到大幅位移 span={span:.1f}px>{max_move},重新计时"
|
||||||
|
)
|
||||||
|
samples.clear()
|
||||||
|
stable_hit_count = 0
|
||||||
|
time.sleep_ms(sample_ms)
|
||||||
|
continue
|
||||||
|
|
||||||
|
window_ms = samples[-1][0] - samples[0][0]
|
||||||
|
if window_ms >= stable_ms and len(samples) >= min_samples:
|
||||||
|
fx = int(round(sum(xs) / len(xs)))
|
||||||
|
fy = int(round(sum(ys) / len(ys)))
|
||||||
|
stable_hit_count += 1
|
||||||
|
if self.logger:
|
||||||
|
self.logger.info(
|
||||||
|
f"[LASER] 远程识别稳定命中 {stable_hit_count}/3 span={span:.1f}px → ({fx}, {fy})"
|
||||||
|
)
|
||||||
|
samples.clear()
|
||||||
|
if stable_hit_count >= 3 and not reported:
|
||||||
|
reported = True
|
||||||
|
self._set_remote_detect_result(
|
||||||
|
{"result":"laser_detect_ok", "x": fx, "y": fy}
|
||||||
|
)
|
||||||
|
if self.logger:
|
||||||
|
self.logger.info(
|
||||||
|
f"[LASER] 已连续3次坐标稳定,完成上报,继续等待 cmd=201 关闭会话"
|
||||||
|
)
|
||||||
|
time.sleep_ms(sample_ms)
|
||||||
|
continue
|
||||||
|
|
||||||
|
time.sleep_ms(sample_ms)
|
||||||
|
finally:
|
||||||
|
if self.logger:
|
||||||
|
self.logger.info("[LASER] 远程识别线程退出,等待下一次 cmd=200")
|
||||||
|
with self._remote_detect_lock:
|
||||||
|
self._remote_detect_active = False
|
||||||
|
|
||||||
def save_laser_point(self, point):
|
def save_laser_point(self, point):
|
||||||
"""保存激光中心点到配置文件
|
"""保存激光中心点到配置文件
|
||||||
如果启用硬编码模式,则不保存(直接返回 True)
|
如果启用硬编码模式,则不保存(直接返回 True)
|
||||||
@@ -828,6 +1065,172 @@ class LaserManager:
|
|||||||
# 使用原来的最亮点方法
|
# 使用原来的最亮点方法
|
||||||
return self._find_red_laser_brightest(frame, threshold, search_radius, ellipse_params)
|
return self._find_red_laser_brightest(frame, threshold, search_radius, ellipse_params)
|
||||||
|
|
||||||
|
def find_red_laser_remote(self, frame):
|
||||||
|
"""
|
||||||
|
cmd=200 远程识别专用:全图搜索、多策略、放宽阈值,不限距画面中心距离。
|
||||||
|
常规 find_red_laser 仅搜中心 ±LASER_SEARCH_RADIUS 且距中心 >50px 会丢弃。
|
||||||
|
"""
|
||||||
|
import cv2
|
||||||
|
import numpy as np
|
||||||
|
from maix import image
|
||||||
|
|
||||||
|
img_cv = image.image2cv(frame, False, False)
|
||||||
|
if img_cv is None or img_cv.size == 0:
|
||||||
|
return None
|
||||||
|
h, w = img_cv.shape[:2]
|
||||||
|
|
||||||
|
r = img_cv[:, :, 0].astype(np.int32)
|
||||||
|
g = img_cv[:, :, 1].astype(np.int32)
|
||||||
|
b = img_cv[:, :, 2].astype(np.int32)
|
||||||
|
brightness = r + g + b
|
||||||
|
red_ratio = float(getattr(config, "LASER_RED_RATIO", 1.5))
|
||||||
|
ratio_lo = max(1.15, red_ratio - 0.35)
|
||||||
|
|
||||||
|
strategies = []
|
||||||
|
base_th = int(getattr(config, "LASER_DETECTION_THRESHOLD", 140))
|
||||||
|
for th in (base_th, 120, 100, 80, 60):
|
||||||
|
mask = (
|
||||||
|
(r > th)
|
||||||
|
& (r > g * ratio_lo)
|
||||||
|
& (r > b * ratio_lo)
|
||||||
|
)
|
||||||
|
strategies.append(("rgb", th, mask))
|
||||||
|
|
||||||
|
oe_th = int(getattr(config, "LASER_OVEREXPOSED_THRESHOLD", 200))
|
||||||
|
oe_diff = int(getattr(config, "LASER_OVEREXPOSED_DIFF", 10))
|
||||||
|
mask_oe = (
|
||||||
|
(r > oe_th - 30)
|
||||||
|
& (g > oe_th - 40)
|
||||||
|
& (b > oe_th - 40)
|
||||||
|
& (r >= g)
|
||||||
|
& (r >= b)
|
||||||
|
& ((r - g) > max(5, oe_diff - 5))
|
||||||
|
& ((r - b) > max(5, oe_diff - 5))
|
||||||
|
)
|
||||||
|
strategies.append(("overexposed", oe_th, mask_oe))
|
||||||
|
|
||||||
|
mask_bright = (brightness > 380) & (r >= g) & (r >= b) & ((r - g) > 3)
|
||||||
|
strategies.append(("bright", 0, mask_bright))
|
||||||
|
|
||||||
|
hsv = cv2.cvtColor(img_cv, cv2.COLOR_RGB2HSV)
|
||||||
|
hc, sc, vc = cv2.split(hsv)
|
||||||
|
mask_hsv = ((hc <= 18) | (hc >= 162)) & (sc >= 60) & (vc >= 60)
|
||||||
|
strategies.append(("hsv", 0, mask_hsv))
|
||||||
|
|
||||||
|
best_pos = None
|
||||||
|
best_score = -1.0
|
||||||
|
best_tag = None
|
||||||
|
|
||||||
|
max_area = float(getattr(config, "LASER_REMOTE_MAX_AREA", 300.0))
|
||||||
|
min_circularity = float(getattr(config, "LASER_REMOTE_MIN_CIRCULARITY", 0.25))
|
||||||
|
|
||||||
|
for name, th, mask in strategies:
|
||||||
|
m = (mask.astype(np.uint8)) * 255
|
||||||
|
if cv2.countNonZero(m) == 0:
|
||||||
|
continue
|
||||||
|
contours, _ = cv2.findContours(m, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE)
|
||||||
|
if not contours:
|
||||||
|
continue
|
||||||
|
for cnt in contours:
|
||||||
|
area = cv2.contourArea(cnt)
|
||||||
|
if area < 1.5 or area > max_area:
|
||||||
|
continue
|
||||||
|
peri = cv2.arcLength(cnt, True)
|
||||||
|
if peri <= 0:
|
||||||
|
continue
|
||||||
|
circularity = float(4.0 * math.pi * area / (peri * peri))
|
||||||
|
if circularity < min_circularity:
|
||||||
|
continue
|
||||||
|
M = cv2.moments(cnt)
|
||||||
|
if M["m00"] <= 0:
|
||||||
|
continue
|
||||||
|
cx = float(M["m10"] / M["m00"])
|
||||||
|
cy = float(M["m01"] / M["m00"])
|
||||||
|
ix, iy = int(round(cx)), int(round(cy))
|
||||||
|
if ix < 0 or iy < 0 or ix >= w or iy >= h:
|
||||||
|
continue
|
||||||
|
local_r = float(r[iy, ix])
|
||||||
|
score = area * local_r * (1.0 + local_r / 255.0) * (0.5 + circularity)
|
||||||
|
if score > best_score:
|
||||||
|
best_score = score
|
||||||
|
best_pos = (ix, iy)
|
||||||
|
best_tag = (name, th, area)
|
||||||
|
|
||||||
|
if best_pos is not None:
|
||||||
|
with self._remote_detect_lock:
|
||||||
|
self._remote_detect_last_pos = best_pos
|
||||||
|
self._save_remote_detect_debug_image(frame, best_pos, best_tag)
|
||||||
|
if self.logger:
|
||||||
|
self.logger.info(
|
||||||
|
f"[LASER-REMOTE] 检测到激光点 {best_pos} "
|
||||||
|
f"strategy={best_tag[0]} th={best_tag[1]} area={best_tag[2]:.1f}"
|
||||||
|
)
|
||||||
|
elif self.logger:
|
||||||
|
self.logger.debug("[LASER-REMOTE] 未通过面积/圆度过滤")
|
||||||
|
return best_pos
|
||||||
|
|
||||||
|
def _save_remote_detect_debug_image(self, frame, pos, tag=None):
|
||||||
|
"""保存远程识别调试图:叠加激光坐标并落盘。"""
|
||||||
|
try:
|
||||||
|
if not bool(getattr(config, "SAVE_IMAGE_ENABLED", True)):
|
||||||
|
return
|
||||||
|
import cv2
|
||||||
|
from maix import image
|
||||||
|
|
||||||
|
img_cv = image.image2cv(frame, False, False)
|
||||||
|
if img_cv is None or img_cv.size == 0:
|
||||||
|
return
|
||||||
|
|
||||||
|
x, y = int(pos[0]), int(pos[1])
|
||||||
|
h, w = img_cv.shape[:2]
|
||||||
|
if x < 0 or y < 0 or x >= w or y >= h:
|
||||||
|
return
|
||||||
|
|
||||||
|
cv2.circle(img_cv, (x, y), 8, (255, 0, 0), 2)
|
||||||
|
cv2.line(img_cv, (x - 12, y), (x + 12, y), (255, 0, 0), 1)
|
||||||
|
cv2.line(img_cv, (x, y - 12), (x, y + 12), (255, 0, 0), 1)
|
||||||
|
|
||||||
|
desc = ""
|
||||||
|
if tag:
|
||||||
|
desc = f" {tag[0]} th={tag[1]} area={tag[2]:.1f}"
|
||||||
|
cv2.putText(
|
||||||
|
img_cv,
|
||||||
|
f"laser=({x},{y}){desc}",
|
||||||
|
(10, 24),
|
||||||
|
cv2.FONT_HERSHEY_SIMPLEX,
|
||||||
|
0.55,
|
||||||
|
(255, 0, 0),
|
||||||
|
1,
|
||||||
|
cv2.LINE_AA,
|
||||||
|
)
|
||||||
|
|
||||||
|
base_dir = getattr(config, "PHOTO_DIR", "/root/phot")
|
||||||
|
debug_dir = f"{base_dir}/laser_remote"
|
||||||
|
try:
|
||||||
|
if debug_dir not in os.listdir("/root") and "/" not in debug_dir.replace("/root/", ""):
|
||||||
|
os.mkdir(debug_dir)
|
||||||
|
else:
|
||||||
|
try:
|
||||||
|
os.makedirs(debug_dir, exist_ok=True)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
except Exception:
|
||||||
|
try:
|
||||||
|
os.makedirs(debug_dir, exist_ok=True)
|
||||||
|
except Exception:
|
||||||
|
return
|
||||||
|
|
||||||
|
ts = int(time.ticks_ms())
|
||||||
|
filename = f"{debug_dir}/remote_{x}_{y}_{ts}.jpg"
|
||||||
|
out = image.cv2image(img_cv, False, False)
|
||||||
|
out.save(filename)
|
||||||
|
|
||||||
|
if self.logger:
|
||||||
|
self.logger.info(f"[LASER-REMOTE] 调试图已保存: {filename}")
|
||||||
|
except Exception as e:
|
||||||
|
if self.logger:
|
||||||
|
self.logger.warning(f"[LASER-REMOTE] 保存调试图失败: {e}")
|
||||||
|
|
||||||
def calibrate_laser_position(self, timeout_ms=8000, check_sharpness=True):
|
def calibrate_laser_position(self, timeout_ms=8000, check_sharpness=True):
|
||||||
"""
|
"""
|
||||||
执行激光校准:循环拍照 → 检测靶心 → 检查激光点清晰度 → 找红点 → 保存坐标
|
执行激光校准:循环拍照 → 检测靶心 → 检查激光点清晰度 → 找红点 → 保存坐标
|
||||||
@@ -1261,28 +1664,6 @@ 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(maxsize=config.LOG_QUEUE_MAXSIZE)
|
self._log_queue = queue.Queue(-1)
|
||||||
|
|
||||||
# 确保日志文件所在的目录存在
|
# 确保日志文件所在的目录存在
|
||||||
log_dir = os.path.dirname(log_file)
|
log_dir = os.path.dirname(log_file)
|
||||||
|
|||||||
@@ -22,7 +22,7 @@ from version import VERSION
|
|||||||
# from logger import init_logging, get_logger, stop_logging
|
# from logger import init_logging, get_logger, stop_logging
|
||||||
from logger_manager import logger_manager
|
from logger_manager import logger_manager
|
||||||
from time_sync import sync_system_time_from_4g
|
from time_sync import sync_system_time_from_4g
|
||||||
from power import charging_shutdown_monitor, init_ina226
|
from power import init_ina226
|
||||||
from laser_manager import laser_manager
|
from laser_manager import laser_manager
|
||||||
from vision import start_save_shot_worker
|
from vision import start_save_shot_worker
|
||||||
from network import network_manager
|
from network import network_manager
|
||||||
@@ -122,16 +122,9 @@ def cmd_str():
|
|||||||
|
|
||||||
# 1. 初始化日志系统
|
# 1. 初始化日志系统
|
||||||
import logging
|
import logging
|
||||||
logger_manager.init_logging(log_level=logging.WARNING)
|
logger_manager.init_logging(log_level=logging.DEBUG)
|
||||||
logger = logger_manager.logger
|
logger = logger_manager.logger
|
||||||
|
|
||||||
# 充电关机独立读取 INA226,不依赖 TCP 连接或心跳流程。
|
|
||||||
try:
|
|
||||||
_thread.start_new_thread(charging_shutdown_monitor, ())
|
|
||||||
except Exception as e:
|
|
||||||
if logger:
|
|
||||||
logger.error(f"[CHARGE] 启动独立监测线程失败: {e}")
|
|
||||||
|
|
||||||
# 补充:因为初始化的时候,激光会亮,先关了它
|
# 补充:因为初始化的时候,激光会亮,先关了它
|
||||||
# laser_manager.turn_off_laser()
|
# laser_manager.turn_off_laser()
|
||||||
|
|
||||||
@@ -290,34 +283,41 @@ 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):
|
||||||
nonlocal pressure_buf, pressure_sum, pressure_min, pressure_max, pressure_t0_ms, logger
|
if not config.AIR_PRESSURE_lOG:
|
||||||
|
return
|
||||||
|
nonlocal pressure_buf, pressure_sum, pressure_min, pressure_max, pressure_t0_ms, logger, pressure_abs_sum, last_avg_abs
|
||||||
if not pressure_buf:
|
if not pressure_buf:
|
||||||
return
|
return
|
||||||
if config.AIR_PRESSURE_lOG:
|
t1_ms = time.ticks_ms()
|
||||||
t1_ms = time.ticks_ms()
|
n = len(pressure_buf)
|
||||||
n = len(pressure_buf)
|
avg = (pressure_sum / n) if n else 0
|
||||||
avg = (pressure_sum / n) if n else 0
|
avg_abs = (pressure_abs_sum / n) if n else 0
|
||||||
line = (
|
# 一行输出:方便后处理画曲线;同时带上统计信息便于快速看波峰
|
||||||
f"[气压批量] reason={reason} "
|
line = (
|
||||||
f"t0={pressure_t0_ms} t1={t1_ms} n={n} "
|
f"[气压批量] reason={reason} "
|
||||||
f"min={pressure_min} max={pressure_max} avg={avg:.1f} "
|
f"t0={pressure_t0_ms} t1={t1_ms} n={n} "
|
||||||
f"values={','.join(map(str, pressure_buf))}"
|
f"min={pressure_min} max={pressure_max} avg={avg:.1f} avg_abs={avg_abs:.3f} "
|
||||||
)
|
f"values={','.join(map(str, pressure_buf))}"
|
||||||
if logger:
|
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}"
|
||||||
logger.debug(line)
|
)
|
||||||
else:
|
if logger:
|
||||||
print(line)
|
logger.debug(line)
|
||||||
# 无论是否记录日志,都必须清空 buffer,否则内存泄漏
|
else:
|
||||||
|
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,7 +336,6 @@ 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} 秒")
|
||||||
@@ -353,10 +352,12 @@ def cmd_str():
|
|||||||
if network_manager.manual_trigger_flag:
|
if network_manager.manual_trigger_flag:
|
||||||
network_manager.clear_manual_trigger()
|
network_manager.clear_manual_trigger()
|
||||||
adc_val = config.ADC_TRIGGER_THRESHOLD + 1
|
adc_val = config.ADC_TRIGGER_THRESHOLD + 1
|
||||||
|
adc_abs_val = 10
|
||||||
if logger:
|
if logger:
|
||||||
logger.info("[TEST] TCP命令触发射箭")
|
logger.info("[TEST] TCP命令触发射箭")
|
||||||
else:
|
else:
|
||||||
adc_val = hardware_manager.adc_obj.read()
|
adc_val = hardware_manager.adc_obj.read()
|
||||||
|
adc_abs_val = hardware_manager.adc_obj.read_vol()
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger = logger_manager.logger
|
logger = logger_manager.logger
|
||||||
if logger:
|
if logger:
|
||||||
@@ -367,8 +368,9 @@ 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)
|
pressure_buf.append((adc_val, adc_abs_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:
|
||||||
@@ -400,7 +402,14 @@ def cmd_str():
|
|||||||
else:
|
else:
|
||||||
if config.SHOW_CAMERA_PHOTO_WHILE_SHOOTING:
|
if config.SHOW_CAMERA_PHOTO_WHILE_SHOOTING:
|
||||||
try:
|
try:
|
||||||
camera_manager.show(camera_manager.read_frame())
|
frame = camera_manager.read_frame()
|
||||||
|
laser_manager.remote_detect_tick(frame)
|
||||||
|
if (
|
||||||
|
laser_manager.remote_detect_active
|
||||||
|
and getattr(config, "LASER_REMOTE_DETECT_DRAW_PREVIEW", False)
|
||||||
|
):
|
||||||
|
frame = laser_manager.overlay_remote_detect_preview(frame)
|
||||||
|
camera_manager.show(frame)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger = logger_manager.logger
|
logger = logger_manager.logger
|
||||||
if logger:
|
if logger:
|
||||||
|
|||||||
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
|
|
||||||
|
|
||||||
+177
-382
@@ -8,7 +8,7 @@ import json
|
|||||||
import re
|
import re
|
||||||
from math import e
|
from math import e
|
||||||
import struct
|
import struct
|
||||||
from maix import time, network, err
|
from maix import time
|
||||||
import hmac
|
import hmac
|
||||||
import hashlib
|
import hashlib
|
||||||
import ujson
|
import ujson
|
||||||
@@ -21,7 +21,8 @@ from hardware import hardware_manager
|
|||||||
from power import get_bus_voltage, voltage_to_percent
|
from power import get_bus_voltage, voltage_to_percent
|
||||||
from logger_manager import logger_manager
|
from logger_manager import logger_manager
|
||||||
from wifi import wifi_manager
|
from wifi import wifi_manager
|
||||||
import subprocess
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
def _wifi_tls_would_block(exc):
|
def _wifi_tls_would_block(exc):
|
||||||
@@ -36,8 +37,8 @@ def _wifi_tls_would_block(exc):
|
|||||||
if _ssl is not None and isinstance(exc, _ssl.SSLError):
|
if _ssl is not None and isinstance(exc, _ssl.SSLError):
|
||||||
err = getattr(exc, "errno", None)
|
err = getattr(exc, "errno", None)
|
||||||
if err in (
|
if err in (
|
||||||
getattr(_ssl, "SSL_ERROR_WANT_READ", 2),
|
getattr(_ssl, "SSL_ERROR_WANT_READ", 2),
|
||||||
getattr(_ssl, "SSL_ERROR_WANT_WRITE", 3),
|
getattr(_ssl, "SSL_ERROR_WANT_WRITE", 3),
|
||||||
):
|
):
|
||||||
return True
|
return True
|
||||||
msg = str(exc).lower()
|
msg = str(exc).lower()
|
||||||
@@ -72,10 +73,6 @@ class NetworkManager:
|
|||||||
self._raw_line_data = []
|
self._raw_line_data = []
|
||||||
self._manual_trigger_flag = False
|
self._manual_trigger_flag = False
|
||||||
|
|
||||||
# 限制并发命令线程数
|
|
||||||
self._cmd_thread_lock = threading.Lock()
|
|
||||||
self._cmd_thread_count = 0
|
|
||||||
|
|
||||||
# 网络类型状态
|
# 网络类型状态
|
||||||
self._network_type = None # "wifi" 或 "4G" 或 None
|
self._network_type = None # "wifi" 或 "4G" 或 None
|
||||||
# 本次上电曾因 WiFi 质量差切换到 4G 后,直至关机不再改回 WiFi
|
# 本次上电曾因 WiFi 质量差切换到 4G 后,直至关机不再改回 WiFi
|
||||||
@@ -87,8 +84,7 @@ class NetworkManager:
|
|||||||
try:
|
try:
|
||||||
import archery_netcore as _netcore
|
import archery_netcore as _netcore
|
||||||
self._netcore = _netcore
|
self._netcore = _netcore
|
||||||
if hasattr(self._netcore, "parse_packet") and hasattr(self._netcore, "make_packet") and hasattr(
|
if hasattr(self._netcore, "parse_packet") and hasattr(self._netcore, "make_packet") and hasattr(self._netcore, "actions_for_inner_cmd"):
|
||||||
self._netcore, "actions_for_inner_cmd"):
|
|
||||||
print("[NET] archery_netcore found")
|
print("[NET] archery_netcore found")
|
||||||
else:
|
else:
|
||||||
print("[NET] archery_netcore not found parse_packet or make_packet")
|
print("[NET] archery_netcore not found parse_packet or make_packet")
|
||||||
@@ -151,6 +147,7 @@ class NetworkManager:
|
|||||||
|
|
||||||
# ==================== 内部状态管理方法 ====================
|
# ==================== 内部状态管理方法 ====================
|
||||||
|
|
||||||
|
|
||||||
def set_manual_trigger(self, value=True):
|
def set_manual_trigger(self, value=True):
|
||||||
"""设置手动触发标志(公共方法)"""
|
"""设置手动触发标志(公共方法)"""
|
||||||
self._manual_trigger_flag = value
|
self._manual_trigger_flag = value
|
||||||
@@ -169,15 +166,11 @@ class NetworkManager:
|
|||||||
self._password = password
|
self._password = password
|
||||||
|
|
||||||
def _enqueue(self, item, high=False):
|
def _enqueue(self, item, high=False):
|
||||||
"""线程安全地加入队列(内部方法),队列满时丢弃最旧消息"""
|
"""线程安全地加入队列(内部方法)"""
|
||||||
with self._queue_lock:
|
with self._queue_lock:
|
||||||
if high:
|
if high:
|
||||||
if len(self._high_send_queue) >= config.MAX_SEND_QUEUE_SIZE:
|
|
||||||
self._high_send_queue.pop(0)
|
|
||||||
self._high_send_queue.append(item)
|
self._high_send_queue.append(item)
|
||||||
else:
|
else:
|
||||||
if len(self._normal_send_queue) >= config.MAX_SEND_QUEUE_SIZE:
|
|
||||||
self._normal_send_queue.pop(0)
|
|
||||||
self._normal_send_queue.append(item)
|
self._normal_send_queue.append(item)
|
||||||
self._send_event.set()
|
self._send_event.set()
|
||||||
|
|
||||||
@@ -206,34 +199,10 @@ class NetworkManager:
|
|||||||
"""获取队列锁(用于with语句)"""
|
"""获取队列锁(用于with语句)"""
|
||||||
return self._queue_lock
|
return self._queue_lock
|
||||||
|
|
||||||
def _spawn_cmd_thread(self, target, args=()):
|
|
||||||
"""安全创建命令线程,限制并发数,防止无限创建导致内存耗尽"""
|
|
||||||
with self._cmd_thread_lock:
|
|
||||||
if self._cmd_thread_count >= config.MAX_CMD_THREADS:
|
|
||||||
self.logger.warning(
|
|
||||||
f"[NET] 并发命令线程已达上限({config.MAX_CMD_THREADS}),跳过: {getattr(target, '__name__', str(target))}"
|
|
||||||
)
|
|
||||||
return False
|
|
||||||
self._cmd_thread_count += 1
|
|
||||||
|
|
||||||
def _wrapper(*a):
|
|
||||||
try:
|
|
||||||
target(*a)
|
|
||||||
except Exception as e:
|
|
||||||
self.logger.error(f"[NET] 命令线程异常: {e}")
|
|
||||||
finally:
|
|
||||||
with self._cmd_thread_lock:
|
|
||||||
self._cmd_thread_count -= 1
|
|
||||||
|
|
||||||
import _thread
|
|
||||||
_thread.start_new_thread(_wrapper, args)
|
|
||||||
return True
|
|
||||||
|
|
||||||
# ==================== 业务方法 ====================
|
# ==================== 业务方法 ====================
|
||||||
|
|
||||||
def read_device_id(self):
|
def read_device_id(self):
|
||||||
"""从 /device_key 文件读取设备唯一 ID,失败则使用默认值"""
|
"""从 /device_key 文件读取设备唯一 ID,失败则使用默认值"""
|
||||||
|
|
||||||
def _set_password_for_device_id(device_id):
|
def _set_password_for_device_id(device_id):
|
||||||
if getattr(config, "USE_TCP_SSL", False):
|
if getattr(config, "USE_TCP_SSL", False):
|
||||||
iccid = self.get_4g_mccid()
|
iccid = self.get_4g_mccid()
|
||||||
@@ -273,7 +242,6 @@ class NetworkManager:
|
|||||||
连接 Wi-Fi:委托 ``wifi_manager.connect_wifi``。
|
连接 Wi-Fi:委托 ``wifi_manager.connect_wifi``。
|
||||||
未指定 ``verify_host``/``verify_port`` 时,可达性校验使用本管理器配置的 ``_server_ip``/``_server_port``。
|
未指定 ``verify_host``/``verify_port`` 时,可达性校验使用本管理器配置的 ``_server_ip``/``_server_port``。
|
||||||
"""
|
"""
|
||||||
|
|
||||||
def _verify(ip: str):
|
def _verify(ip: str):
|
||||||
v_host = verify_host if verify_host is not None else self._server_ip
|
v_host = verify_host if verify_host is not None else self._server_ip
|
||||||
v_port = verify_port if verify_port is not None else self._server_port
|
v_port = verify_port if verify_port is not None else self._server_port
|
||||||
@@ -331,10 +299,7 @@ class NetworkManager:
|
|||||||
if atc is None:
|
if atc is None:
|
||||||
return False
|
return False
|
||||||
|
|
||||||
if not self._uart4g_lock.acquire(timeout=3000):
|
with self.get_uart_lock():
|
||||||
self.logger.warning("[4G] 获取 uart4g_lock 超时,跳过 4G 可用性检查")
|
|
||||||
return False
|
|
||||||
try:
|
|
||||||
# 1) SIM 就绪
|
# 1) SIM 就绪
|
||||||
r = atc.send("AT+CPIN?", "READY", 3000)
|
r = atc.send("AT+CPIN?", "READY", 3000)
|
||||||
if "READY" not in r:
|
if "READY" not in r:
|
||||||
@@ -375,8 +340,6 @@ class NetworkManager:
|
|||||||
if ip2:
|
if ip2:
|
||||||
return True
|
return True
|
||||||
return False
|
return False
|
||||||
finally:
|
|
||||||
self._uart4g_lock.release()
|
|
||||||
except Exception:
|
except Exception:
|
||||||
return False
|
return False
|
||||||
|
|
||||||
@@ -392,13 +355,8 @@ class NetworkManager:
|
|||||||
atc = hardware_manager.at_client
|
atc = hardware_manager.at_client
|
||||||
if atc is None:
|
if atc is None:
|
||||||
return None
|
return None
|
||||||
if not self._uart4g_lock.acquire(timeout=3000):
|
with self.get_uart_lock():
|
||||||
self.logger.warning("[4G] get_4g_phone_number 获取锁超时")
|
|
||||||
return None
|
|
||||||
try:
|
|
||||||
resp = atc.send("AT+CNUM", "OK", 3000)
|
resp = atc.send("AT+CNUM", "OK", 3000)
|
||||||
finally:
|
|
||||||
self._uart4g_lock.release()
|
|
||||||
if not resp:
|
if not resp:
|
||||||
return None
|
return None
|
||||||
# 可能多行 +CNUM,取第一个非空号码
|
# 可能多行 +CNUM,取第一个非空号码
|
||||||
@@ -421,13 +379,8 @@ class NetworkManager:
|
|||||||
atc = hardware_manager.at_client
|
atc = hardware_manager.at_client
|
||||||
if atc is None:
|
if atc is None:
|
||||||
return None
|
return None
|
||||||
if not self._uart4g_lock.acquire(timeout=3000):
|
with self.get_uart_lock():
|
||||||
self.logger.warning("[4G] get_4g_mccid 获取锁超时")
|
|
||||||
return None
|
|
||||||
try:
|
|
||||||
resp = atc.send("AT+MCCID", "OK", 3000)
|
resp = atc.send("AT+MCCID", "OK", 3000)
|
||||||
finally:
|
|
||||||
self._uart4g_lock.release()
|
|
||||||
if not resp or "ERROR" in resp.upper():
|
if not resp or "ERROR" in resp.upper():
|
||||||
return None
|
return None
|
||||||
m = re.search(r"\+MCCID:\s*(.+)", resp, re.IGNORECASE)
|
m = re.search(r"\+MCCID:\s*(.+)", resp, re.IGNORECASE)
|
||||||
@@ -584,136 +537,13 @@ class NetworkManager:
|
|||||||
self._session_force_4g = False
|
self._session_force_4g = False
|
||||||
return False
|
return False
|
||||||
|
|
||||||
def _cmd200_detect_laser(self):
|
|
||||||
"""后台线程执行 cmd200 激光检测,避免阻塞主循环"""
|
|
||||||
from laser_manager import laser_manager
|
|
||||||
try:
|
|
||||||
laser_manager.turn_on_laser()
|
|
||||||
self.logger.info("[LASER] cmd200 已发送开激光指令")
|
|
||||||
except Exception as e:
|
|
||||||
self.logger.warning(f"[LASER] cmd200 开激光异常: {e}")
|
|
||||||
|
|
||||||
try:
|
|
||||||
from laser_detector import get_stable_laser_point
|
|
||||||
time.sleep_ms(500)
|
|
||||||
result = get_stable_laser_point(timeout_ms=60000)
|
|
||||||
if result:
|
|
||||||
x, y = result
|
|
||||||
self.safe_enqueue({
|
|
||||||
"cmd": 200,
|
|
||||||
"result": "laser_detect_ok",
|
|
||||||
"x": x,
|
|
||||||
"y": y,
|
|
||||||
}, 2)
|
|
||||||
self.logger.info(f"[LASER] cmd200 检测结果: ({x}, {y})")
|
|
||||||
else:
|
|
||||||
self.safe_enqueue({
|
|
||||||
"cmd": 200,
|
|
||||||
"result": "laser_detect_failed",
|
|
||||||
}, 2)
|
|
||||||
self.logger.warning("[LASER] cmd200 检测失败")
|
|
||||||
except Exception as e:
|
|
||||||
self.logger.error(f"[LASER] cmd200 检测异常: {e}")
|
|
||||||
|
|
||||||
def _cmd300_ota(self, data_obj):
|
|
||||||
"""后台线程执行 cmd300 OTA,避免阻塞主循环"""
|
|
||||||
hardware_manager.start_idle_timer()
|
|
||||||
inner_data = data_obj.get("data", {}) if isinstance(data_obj, dict) else {}
|
|
||||||
self.logger.info(f"[New Ota] cmd300 , data: {inner_data}")
|
|
||||||
ssid = inner_data.get("ssid")
|
|
||||||
password = inner_data.get("password")
|
|
||||||
ota_res_url = inner_data.get("url")
|
|
||||||
try:
|
|
||||||
for _f in ("/etc/wpa_supplicant.conf", "/boot/wpa_supplicant.conf", "/boot/wifi.ssid", "/boot/wifi.pass"):
|
|
||||||
try:
|
|
||||||
os.remove(_f)
|
|
||||||
except OSError:
|
|
||||||
pass
|
|
||||||
w = network.wifi.Wifi()
|
|
||||||
e = w.connect(ssid, password, wait=True, timeout=15)
|
|
||||||
err.check_raise(e, "connect wifi failed")
|
|
||||||
if self.logger:
|
|
||||||
self.logger.info(f"[ota] Connect success, got ip{w.get_ip()}")
|
|
||||||
self.safe_enqueue(
|
|
||||||
{
|
|
||||||
"cmd": 300,
|
|
||||||
"result": "ota start...",
|
|
||||||
"wifi": w.get_ip(),
|
|
||||||
},
|
|
||||||
2,
|
|
||||||
)
|
|
||||||
subprocess.run(
|
|
||||||
["sh", "/maixapp/apps/t11/ota_curl.sh", ota_res_url])
|
|
||||||
self.safe_enqueue(
|
|
||||||
{
|
|
||||||
"cmd": 300,
|
|
||||||
"result": "success",
|
|
||||||
"wifi": w.get_ip(),
|
|
||||||
},
|
|
||||||
2,
|
|
||||||
)
|
|
||||||
except Exception as e:
|
|
||||||
self.logger.error(f"[ota] cmd300 失败: {e}")
|
|
||||||
self.safe_enqueue(
|
|
||||||
{
|
|
||||||
"cmd": 300,
|
|
||||||
"result": "ota fail",
|
|
||||||
"reason": str(e),
|
|
||||||
},
|
|
||||||
2,
|
|
||||||
)
|
|
||||||
|
|
||||||
def _cmd600_conn_wifi(self, data_obj):
|
|
||||||
hardware_manager.start_idle_timer()
|
|
||||||
inner_data = data_obj.get("data", {}) if isinstance(data_obj, dict) else {}
|
|
||||||
self.logger.info(f"[conn wifi] cmd600 , data: {inner_data}")
|
|
||||||
ssid = inner_data.get("ssid")
|
|
||||||
password = inner_data.get("password")
|
|
||||||
try:
|
|
||||||
for _f in ("/etc/wpa_supplicant.conf", "/boot/wpa_supplicant.conf", "/boot/wifi.ssid", "/boot/wifi.pass"):
|
|
||||||
try:
|
|
||||||
os.remove(_f)
|
|
||||||
except OSError:
|
|
||||||
pass
|
|
||||||
w = network.wifi.Wifi()
|
|
||||||
e = w.connect(ssid, password, wait=True, timeout=15)
|
|
||||||
err.check_raise(e, "connect wifi failed")
|
|
||||||
if self.logger:
|
|
||||||
self.logger.info(f"[ota] Connect success, got ip{w.get_ip()}")
|
|
||||||
self.safe_enqueue(
|
|
||||||
{
|
|
||||||
"cmd": 600,
|
|
||||||
"result": "success",
|
|
||||||
"wifi": w.get_ip(),
|
|
||||||
},
|
|
||||||
2,
|
|
||||||
)
|
|
||||||
self._session_force_4g = False
|
|
||||||
self.disconnect_server()
|
|
||||||
self._tcp_connected = False
|
|
||||||
self._network_type = None
|
|
||||||
self.logger.info("[conn wifi] WiFi已连接,等待主循环重新登录")
|
|
||||||
except Exception as e:
|
|
||||||
self.logger.error(f"cmd600 失败: {e}")
|
|
||||||
self.safe_enqueue(
|
|
||||||
{
|
|
||||||
"cmd": 600,
|
|
||||||
"result": "conn fail",
|
|
||||||
"reason": str(e),
|
|
||||||
},
|
|
||||||
2,
|
|
||||||
)
|
|
||||||
self._switch_to_4g_due_to_poor_wifi()
|
|
||||||
|
|
||||||
def safe_enqueue(self, data_dict, msg_type=2, high=False):
|
def safe_enqueue(self, data_dict, msg_type=2, high=False):
|
||||||
"""线程安全地将消息加入队列(公共方法)"""
|
"""线程安全地将消息加入队列(公共方法)"""
|
||||||
self._enqueue((msg_type, data_dict), high)
|
self._enqueue((msg_type, data_dict), high)
|
||||||
|
|
||||||
def safe_enqueue_and_wait(self, data_dict, msg_type=2, high=False, timeout_ms=30000):
|
|
||||||
"""将消息加入队列,并等待网络线程确认已写入 TCP 连接。"""
|
|
||||||
sent_event = threading.Event()
|
|
||||||
self._enqueue((msg_type, data_dict, sent_event), high)
|
|
||||||
return bool(sent_event.wait(max(0, int(timeout_ms)) / 1000.0))
|
|
||||||
|
|
||||||
def connect_server(self):
|
def connect_server(self):
|
||||||
"""
|
"""
|
||||||
@@ -727,7 +557,7 @@ class NetworkManager:
|
|||||||
if self._network_type == "wifi":
|
if self._network_type == "wifi":
|
||||||
return self._check_wifi_connection()
|
return self._check_wifi_connection()
|
||||||
elif self._network_type == "4g":
|
elif self._network_type == "4g":
|
||||||
return self._check_4g_connection()
|
return True # 4G连接状态由AT命令维护
|
||||||
return False
|
return False
|
||||||
|
|
||||||
# 自动选择网络
|
# 自动选择网络
|
||||||
@@ -744,37 +574,6 @@ class NetworkManager:
|
|||||||
return self._connect_tcp_via_4g()
|
return self._connect_tcp_via_4g()
|
||||||
return False
|
return False
|
||||||
|
|
||||||
def _check_4g_connection(self):
|
|
||||||
"""检查4G TCP连接是否仍然有效(通过查询PDP地址验证网络附着状态)"""
|
|
||||||
try:
|
|
||||||
atc = hardware_manager.at_client
|
|
||||||
if atc is None:
|
|
||||||
return False
|
|
||||||
if not self._uart4g_lock.acquire(timeout=3000):
|
|
||||||
# 获取锁超时说明有其他操作在进行,视为连接仍有效
|
|
||||||
return True
|
|
||||||
try:
|
|
||||||
r = atc.send("AT+CGPADDR=1", "OK", 3000)
|
|
||||||
m = re.search(r'\+CGPADDR:\s*1,"([^"]+)"', r)
|
|
||||||
ip = m.group(1) if m else ""
|
|
||||||
if ip and ip != "0.0.0.0":
|
|
||||||
return True
|
|
||||||
# 无IP或IP无效,尝试重新激活PDP
|
|
||||||
self.logger.warning("[4G-TCP] PDP地址无效,尝试重新激活")
|
|
||||||
atc.send("AT+MIPCALL=1,1", "OK", 15000)
|
|
||||||
r2 = atc.send("AT+CGPADDR=1", "OK", 3000)
|
|
||||||
m2 = re.search(r'\+CGPADDR:\s*1,"([^"]+)"', r2)
|
|
||||||
ip2 = m2.group(1) if m2 else ""
|
|
||||||
if ip2 and ip2 != "0.0.0.0":
|
|
||||||
return True
|
|
||||||
self.logger.error("[4G-TCP] 重新激活PDP仍无有效IP,连接已断开")
|
|
||||||
return False
|
|
||||||
finally:
|
|
||||||
self._uart4g_lock.release()
|
|
||||||
except Exception as e:
|
|
||||||
self.logger.warning(f"[4G-TCP] 连接检查异常: {e}")
|
|
||||||
return True # 异常时不误判断线
|
|
||||||
|
|
||||||
def _wrap_wifi_tls(self, plain_sock, hostname):
|
def _wrap_wifi_tls(self, plain_sock, hostname):
|
||||||
"""
|
"""
|
||||||
在已建立的 TCP socket 上做 TLS(WiFi 走主机 ssl 库;4G 仍用模组 AT+SSL)。
|
在已建立的 TCP socket 上做 TLS(WiFi 走主机 ssl 库;4G 仍用模组 AT+SSL)。
|
||||||
@@ -873,10 +672,7 @@ class NetworkManager:
|
|||||||
host = self._server_ip
|
host = self._server_ip
|
||||||
port = getattr(config, "TCP_SSL_PORT", 443) if use_ssl else config.SERVER_PORT
|
port = getattr(config, "TCP_SSL_PORT", 443) if use_ssl else config.SERVER_PORT
|
||||||
tail = getattr(config, "MIPOPEN_TAIL", "")
|
tail = getattr(config, "MIPOPEN_TAIL", "")
|
||||||
if not self._uart4g_lock.acquire(timeout=15000):
|
with self.get_uart_lock():
|
||||||
self.logger.warning("[4G-TCP] 连接:获取 uart4g_lock 超时")
|
|
||||||
return False
|
|
||||||
try:
|
|
||||||
resp = hardware_manager.at_client.send(f"AT+MIPCLOSE={link_id}", "OK", 1000)
|
resp = hardware_manager.at_client.send(f"AT+MIPCLOSE={link_id}", "OK", 1000)
|
||||||
self.logger.info(f"[4G-TCP] AT+MIPCLOSE={link_id} response: {resp}")
|
self.logger.info(f"[4G-TCP] AT+MIPCLOSE={link_id} response: {resp}")
|
||||||
|
|
||||||
@@ -890,8 +686,6 @@ class NetworkManager:
|
|||||||
cmd = f'AT+MIPOPEN={link_id},"TCP","{host}",{port}'
|
cmd = f'AT+MIPOPEN={link_id},"TCP","{host}",{port}'
|
||||||
res = hardware_manager.at_client.send(cmd, "+MIPOPEN", 8000)
|
res = hardware_manager.at_client.send(cmd, "+MIPOPEN", 8000)
|
||||||
self.logger.info(f"[4G-TCP] {cmd} response: {res}")
|
self.logger.info(f"[4G-TCP] {cmd} response: {res}")
|
||||||
finally:
|
|
||||||
self._uart4g_lock.release()
|
|
||||||
if f"+MIPOPEN: {link_id},0" in res:
|
if f"+MIPOPEN: {link_id},0" in res:
|
||||||
self._tcp_connected = True
|
self._tcp_connected = True
|
||||||
return True
|
return True
|
||||||
@@ -901,12 +695,6 @@ class NetworkManager:
|
|||||||
"""检查WiFi TCP连接是否仍然有效"""
|
"""检查WiFi TCP连接是否仍然有效"""
|
||||||
if not wifi_manager.wifi_socket:
|
if not wifi_manager.wifi_socket:
|
||||||
return False
|
return False
|
||||||
# TLS socket 无法可靠使用 MSG_PEEK,但物理 WiFi 链路仍可通过 STA 关联状态判断。
|
|
||||||
if not wifi_manager.is_sta_associated():
|
|
||||||
self.logger.warning("[WIFI-TCP] STA 已断开,关闭 WiFi TCP 并重新选网")
|
|
||||||
wifi_manager.disconnect_wifi()
|
|
||||||
self._tcp_connected = False
|
|
||||||
return False
|
|
||||||
# TLS(ssl.wrap_socket/SSLContext.wrap_socket) 后的 socket 往往不支持 MSG_PEEK/MSG_DONTWAIT。
|
# TLS(ssl.wrap_socket/SSLContext.wrap_socket) 后的 socket 往往不支持 MSG_PEEK/MSG_DONTWAIT。
|
||||||
# 这种情况下“主动探测”反而容易误报断线;让真正的 send/recv 去判定更稳。
|
# 这种情况下“主动探测”反而容易误报断线;让真正的 send/recv 去判定更稳。
|
||||||
try:
|
try:
|
||||||
@@ -1020,13 +808,9 @@ class NetworkManager:
|
|||||||
|
|
||||||
def _disconnect_tcp_via_4g(self):
|
def _disconnect_tcp_via_4g(self):
|
||||||
link_id = getattr(config, "TCP_LINK_ID", 0)
|
link_id = getattr(config, "TCP_LINK_ID", 0)
|
||||||
if not self._uart4g_lock.acquire(timeout=2000):
|
with self.get_uart_lock():
|
||||||
self.logger.warning("[4G-TCP] 断开连接:获取 uart4g_lock 超时")
|
|
||||||
return
|
|
||||||
try:
|
|
||||||
hardware_manager.at_client.send(f"AT+MIPCLOSE={link_id}", "OK", 1000)
|
hardware_manager.at_client.send(f"AT+MIPCLOSE={link_id}", "OK", 1000)
|
||||||
finally:
|
|
||||||
self._uart4g_lock.release()
|
|
||||||
|
|
||||||
def tcp_send_raw(self, data: bytes, max_retries=2) -> bool:
|
def tcp_send_raw(self, data: bytes, max_retries=2) -> bool:
|
||||||
"""
|
"""
|
||||||
@@ -1075,7 +859,7 @@ class NetworkManager:
|
|||||||
raise
|
raise
|
||||||
if sent == 0:
|
if sent == 0:
|
||||||
# socket连接已断开
|
# socket连接已断开
|
||||||
self.logger.warning(f"[WIFI-TCP] 发送失败,socket已断开(尝试 {attempt + 1}/{max_retries})")
|
self.logger.warning(f"[WIFI-TCP] 发送失败,socket已断开(尝试 {attempt+1}/{max_retries})")
|
||||||
raise OSError("wifi socket closed (send returned 0)")
|
raise OSError("wifi socket closed (send returned 0)")
|
||||||
total_sent += sent
|
total_sent += sent
|
||||||
|
|
||||||
@@ -1086,7 +870,7 @@ class NetworkManager:
|
|||||||
time.sleep_ms(50)
|
time.sleep_ms(50)
|
||||||
|
|
||||||
except OSError as e:
|
except OSError as e:
|
||||||
self.logger.error(f"[WIFI-TCP] 发送异常: {e}(尝试 {attempt + 1}/{max_retries})")
|
self.logger.error(f"[WIFI-TCP] 发送异常: {e}(尝试 {attempt+1}/{max_retries})")
|
||||||
# 发送异常通常意味着连接已不可用,主动关闭以触发重连
|
# 发送异常通常意味着连接已不可用,主动关闭以触发重连
|
||||||
try:
|
try:
|
||||||
wifi_manager.wifi_socket.close()
|
wifi_manager.wifi_socket.close()
|
||||||
@@ -1096,7 +880,7 @@ class NetworkManager:
|
|||||||
self._tcp_connected = False
|
self._tcp_connected = False
|
||||||
return False
|
return False
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
self.logger.error(f"[WIFI-TCP] 未知错误: {e}(尝试 {attempt + 1}/{max_retries})")
|
self.logger.error(f"[WIFI-TCP] 未知错误: {e}(尝试 {attempt+1}/{max_retries})")
|
||||||
try:
|
try:
|
||||||
wifi_manager.wifi_socket.close()
|
wifi_manager.wifi_socket.close()
|
||||||
except:
|
except:
|
||||||
@@ -1109,10 +893,7 @@ class NetworkManager:
|
|||||||
|
|
||||||
def _tcp_send_raw_via_4g(self, data: bytes, max_retries=2) -> bool:
|
def _tcp_send_raw_via_4g(self, data: bytes, max_retries=2) -> bool:
|
||||||
link_id = getattr(config, "TCP_LINK_ID", 0)
|
link_id = getattr(config, "TCP_LINK_ID", 0)
|
||||||
if not self._uart4g_lock.acquire(timeout=2000):
|
with self.get_uart_lock():
|
||||||
self.logger.warning("[4G-TCP] 获取 uart4g_lock 超时(其他线程持有),跳过本次发送")
|
|
||||||
return False
|
|
||||||
try:
|
|
||||||
for _ in range(max_retries):
|
for _ in range(max_retries):
|
||||||
cmd = f'AT+MIPSEND={link_id},{len(data)}'
|
cmd = f'AT+MIPSEND={link_id},{len(data)}'
|
||||||
if ">" not in hardware_manager.at_client.send(cmd, ">", 2000):
|
if ">" not in hardware_manager.at_client.send(cmd, ">", 2000):
|
||||||
@@ -1128,15 +909,11 @@ class NetworkManager:
|
|||||||
total += n
|
total += n
|
||||||
|
|
||||||
hardware_manager.uart4g.write(b"\x1A")
|
hardware_manager.uart4g.write(b"\x1A")
|
||||||
with hardware_manager.at_client._q_lock:
|
|
||||||
hardware_manager.at_client._rx = b""
|
|
||||||
r = hardware_manager.at_client.send("", "OK", 8000)
|
r = hardware_manager.at_client.send("", "OK", 8000)
|
||||||
if ("SEND OK" in r) or ("OK" in r) or ("+MIPSEND" in r):
|
if ("SEND OK" in r) or ("OK" in r) or ("+MIPSEND" in r):
|
||||||
return True
|
return True
|
||||||
time.sleep_ms(50)
|
time.sleep_ms(50)
|
||||||
return False
|
return False
|
||||||
finally:
|
|
||||||
self._uart4g_lock.release()
|
|
||||||
|
|
||||||
def _configure_ssl_before_connect(self, link_id: int) -> bool:
|
def _configure_ssl_before_connect(self, link_id: int) -> bool:
|
||||||
"""按手册:MSSLCFG(auth) -> (可选) MSSLCERTWR -> MSSLCFG(cert) -> MIPCFG(ssl)"""
|
"""按手册:MSSLCFG(auth) -> (可选) MSSLCERTWR -> MSSLCFG(cert) -> MIPCFG(ssl)"""
|
||||||
@@ -1189,6 +966,7 @@ class NetworkManager:
|
|||||||
r = hardware_manager.at_client.send(f'AT+MSSLCERTRD="{cert_filename}"', "OK", 3000)
|
r = hardware_manager.at_client.send(f'AT+MSSLCERTRD="{cert_filename}"', "OK", 3000)
|
||||||
self.logger.info(f"[4G-TCP] AT+MSSLCERTRD=\"{cert_filename}\" response: {r}")
|
self.logger.info(f"[4G-TCP] AT+MSSLCERTRD=\"{cert_filename}\" response: {r}")
|
||||||
|
|
||||||
|
|
||||||
# 3) 引用根证书
|
# 3) 引用根证书
|
||||||
r = hardware_manager.at_client.send(f'AT+MSSLCFG="cert",{ssl_id},"{cert_filename}"', "OK", 3000)
|
r = hardware_manager.at_client.send(f'AT+MSSLCFG="cert",{ssl_id},"{cert_filename}"', "OK", 3000)
|
||||||
if "OK" not in r:
|
if "OK" not in r:
|
||||||
@@ -1222,14 +1000,6 @@ class NetworkManager:
|
|||||||
# 这里保持 socket 为非阻塞模式(连接时已 setblocking(False))。
|
# 这里保持 socket 为非阻塞模式(连接时已 setblocking(False))。
|
||||||
# 不要反复 settimeout(),否则会把 socket 切回"阻塞+超时",并导致 conncheck 误报 timed out。
|
# 不要反复 settimeout(),否则会把 socket 切回"阻塞+超时",并导致 conncheck 误报 timed out。
|
||||||
data = wifi_manager.wifi_socket.recv(4096) # 每次最多接收4KB(无数据会抛 BlockingIOError)
|
data = wifi_manager.wifi_socket.recv(4096) # 每次最多接收4KB(无数据会抛 BlockingIOError)
|
||||||
if data == b"":
|
|
||||||
self.logger.warning("[WIFI-TCP] 对端已关闭连接")
|
|
||||||
try:
|
|
||||||
wifi_manager.wifi_socket.close()
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
wifi_manager.wifi_socket = None
|
|
||||||
self._tcp_connected = False
|
|
||||||
return data
|
return data
|
||||||
|
|
||||||
except BlockingIOError:
|
except BlockingIOError:
|
||||||
@@ -1254,8 +1024,7 @@ class NetworkManager:
|
|||||||
self.logger.error(f"[WIFI-TCP] 接收数据异常: {e}")
|
self.logger.error(f"[WIFI-TCP] 接收数据异常: {e}")
|
||||||
return b""
|
return b""
|
||||||
|
|
||||||
def _upload_log_file(self, upload_url, wifi_ssid=None, wifi_password=None, include_rotated=True, max_files=None,
|
def _upload_log_file(self, upload_url, wifi_ssid=None, wifi_password=None, include_rotated=True, max_files=None, archive_format="tgz"):
|
||||||
archive_format="tgz"):
|
|
||||||
"""上传日志文件到指定URL
|
"""上传日志文件到指定URL
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
@@ -1388,8 +1157,7 @@ class NetworkManager:
|
|||||||
staged_paths.append(dst)
|
staged_paths.append(dst)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
self.logger.error(f"[LOG_UPLOAD] 复制日志快照失败: {e}")
|
self.logger.error(f"[LOG_UPLOAD] 复制日志快照失败: {e}")
|
||||||
self.safe_enqueue({"result": "log_upload_failed", "reason": "snapshot_failed", "detail": str(e)[:100]},
|
self.safe_enqueue({"result": "log_upload_failed", "reason": "snapshot_failed", "detail": str(e)[:100]}, 2)
|
||||||
2)
|
|
||||||
try:
|
try:
|
||||||
shutil.rmtree(staging_dir)
|
shutil.rmtree(staging_dir)
|
||||||
except:
|
except:
|
||||||
@@ -1417,8 +1185,7 @@ class NetworkManager:
|
|||||||
self.logger.info(f"[LOG_UPLOAD] 日志压缩包已生成: {archive_path}")
|
self.logger.info(f"[LOG_UPLOAD] 日志压缩包已生成: {archive_path}")
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
self.logger.error(f"[LOG_UPLOAD] 打包压缩失败: {e}")
|
self.logger.error(f"[LOG_UPLOAD] 打包压缩失败: {e}")
|
||||||
self.safe_enqueue({"result": "log_upload_failed", "reason": "archive_failed", "detail": str(e)[:100]},
|
self.safe_enqueue({"result": "log_upload_failed", "reason": "archive_failed", "detail": str(e)[:100]}, 2)
|
||||||
2)
|
|
||||||
try:
|
try:
|
||||||
shutil.rmtree(staging_dir)
|
shutil.rmtree(staging_dir)
|
||||||
except:
|
except:
|
||||||
@@ -1467,8 +1234,7 @@ class NetworkManager:
|
|||||||
"status_code": response.status_code
|
"status_code": response.status_code
|
||||||
}, 2)
|
}, 2)
|
||||||
else:
|
else:
|
||||||
self.logger.error(
|
self.logger.error(f"[LOG_UPLOAD] 上传失败! 状态码: {response.status_code}, 响应: {response.text[:200]}")
|
||||||
f"[LOG_UPLOAD] 上传失败! 状态码: {response.status_code}, 响应: {response.text[:200]}")
|
|
||||||
self.safe_enqueue({
|
self.safe_enqueue({
|
||||||
"result": "log_upload_failed",
|
"result": "log_upload_failed",
|
||||||
"reason": f"http_{response.status_code}",
|
"reason": f"http_{response.status_code}",
|
||||||
@@ -1604,8 +1370,7 @@ class NetworkManager:
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
return None, f"prepare_exception: {e}"
|
return None, f"prepare_exception: {e}"
|
||||||
|
|
||||||
def _upload_log_file_v2(self, upload_url, upload_token, key, outlink="", include_rotated=True, max_files=None,
|
def _upload_log_file_v2(self, upload_url, upload_token, key, outlink="", include_rotated=True, max_files=None, archive_format="tgz"):
|
||||||
archive_format="tgz"):
|
|
||||||
"""上传日志到 Qiniu(支持 WiFi 和 4G 双路径)
|
"""上传日志到 Qiniu(支持 WiFi 和 4G 双路径)
|
||||||
|
|
||||||
流程:准备日志归档 -> 自动检测网络 -> WiFi(requests) 或 4G(AT命令) 上传
|
流程:准备日志归档 -> 自动检测网络 -> WiFi(requests) 或 4G(AT命令) 上传
|
||||||
@@ -1827,12 +1592,21 @@ class NetworkManager:
|
|||||||
|
|
||||||
def tcp_main(self):
|
def tcp_main(self):
|
||||||
"""TCP 主通信循环:登录、心跳、处理指令、发送数据"""
|
"""TCP 主通信循环:登录、心跳、处理指令、发送数据"""
|
||||||
|
import _thread
|
||||||
|
|
||||||
self.logger.info("[NET] TCP主线程启动")
|
self.logger.info("[NET] TCP主线程启动")
|
||||||
|
|
||||||
send_hartbeat_fail_count = 0
|
send_hartbeat_fail_count = 0
|
||||||
|
last_charging_check = 0
|
||||||
|
CHARGING_CHECK_INTERVAL = 5000 # 5秒检查一次充电状态
|
||||||
|
|
||||||
while True:
|
while True:
|
||||||
try:
|
try:
|
||||||
|
# 检查充电状态(每5秒检查一次)
|
||||||
|
current_time = time.ticks_ms()
|
||||||
|
if current_time - last_charging_check > CHARGING_CHECK_INTERVAL:
|
||||||
|
last_charging_check = current_time
|
||||||
|
|
||||||
# OTA 期间不要 connect/登录/心跳/发送
|
# OTA 期间不要 connect/登录/心跳/发送
|
||||||
try:
|
try:
|
||||||
from ota_manager import ota_manager
|
from ota_manager import ota_manager
|
||||||
@@ -1845,7 +1619,7 @@ class NetworkManager:
|
|||||||
continue
|
continue
|
||||||
|
|
||||||
if not self.connect_server():
|
if not self.connect_server():
|
||||||
time.sleep_ms(1000)
|
time.sleep_ms(5000)
|
||||||
continue
|
continue
|
||||||
|
|
||||||
# 发送登录包
|
# 发送登录包
|
||||||
@@ -1866,7 +1640,7 @@ class NetworkManager:
|
|||||||
self.disconnect_server()
|
self.disconnect_server()
|
||||||
except:
|
except:
|
||||||
pass
|
pass
|
||||||
time.sleep_ms(500)
|
time.sleep_ms(2000)
|
||||||
continue
|
continue
|
||||||
|
|
||||||
self.logger.info("➡️ 登录包已发送,等待确认...")
|
self.logger.info("➡️ 登录包已发送,等待确认...")
|
||||||
@@ -1931,8 +1705,7 @@ class NetworkManager:
|
|||||||
|
|
||||||
if not logged_in:
|
if not logged_in:
|
||||||
try:
|
try:
|
||||||
self.logger.debug(
|
self.logger.debug(f"[TCP] rx link={link_id} len={len(payload)} head={payload[:12].hex()}")
|
||||||
f"[TCP] rx link={link_id} len={len(payload)} head={payload[:12].hex()}")
|
|
||||||
except:
|
except:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
@@ -1958,8 +1731,7 @@ class NetworkManager:
|
|||||||
pending_obj = json.load(f)
|
pending_obj = json.load(f)
|
||||||
except:
|
except:
|
||||||
pending_obj = {}
|
pending_obj = {}
|
||||||
self.safe_enqueue({"result": "ota_ok", "url": pending_obj.get("url", "")},
|
self.safe_enqueue({"result": "ota_ok", "url": pending_obj.get("url", "")}, 2)
|
||||||
2)
|
|
||||||
self.logger.info("[OTA] 已上报 ota_ok,等待心跳确认后删除 pending")
|
self.logger.info("[OTA] 已上报 ota_ok,等待心跳确认后删除 pending")
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
self.logger.error(f"[OTA] ota_ok 上报失败: {e}")
|
self.logger.error(f"[OTA] ota_ok 上报失败: {e}")
|
||||||
@@ -1978,8 +1750,7 @@ class NetworkManager:
|
|||||||
t = body.get('t', 0)
|
t = body.get('t', 0)
|
||||||
v = body.get('v')
|
v = body.get('v')
|
||||||
# 如果是第一个分片,清空之前的缓存
|
# 如果是第一个分片,清空之前的缓存
|
||||||
if len(self._raw_line_data) == 0 or (
|
if len(self._raw_line_data) == 0 or (len(self._raw_line_data) > 0 and self._raw_line_data[0].get('v') != v):
|
||||||
len(self._raw_line_data) > 0 and self._raw_line_data[0].get('v') != v):
|
|
||||||
self._raw_line_data.clear()
|
self._raw_line_data.clear()
|
||||||
# 或者更简单:每次收到命令40时,如果版本号不同,清空缓存
|
# 或者更简单:每次收到命令40时,如果版本号不同,清空缓存
|
||||||
if len(self._raw_line_data) > 0:
|
if len(self._raw_line_data) > 0:
|
||||||
@@ -1996,7 +1767,7 @@ class NetworkManager:
|
|||||||
file.write("\n".join(stock_array))
|
file.write("\n".join(stock_array))
|
||||||
ota_manager.apply_ota_and_reboot(None, local_filename)
|
ota_manager.apply_ota_and_reboot(None, local_filename)
|
||||||
else:
|
else:
|
||||||
self.safe_enqueue({'data': {'l': len(self._raw_line_data), 'v': v}, 'cmd': 41})
|
self.safe_enqueue({'data':{'l': len(self._raw_line_data), 'v': v}, 'cmd': 41})
|
||||||
self.logger.info(f"已下载{len(self._raw_line_data)} 全部:{t} 版本:{v}")
|
self.logger.info(f"已下载{len(self._raw_line_data)} 全部:{t} 版本:{v}")
|
||||||
|
|
||||||
elif logged_in and msg_type == 100:
|
elif logged_in and msg_type == 100:
|
||||||
@@ -2013,8 +1784,7 @@ class NetworkManager:
|
|||||||
# 验证必需字段
|
# 验证必需字段
|
||||||
if not upload_url or not upload_token or not shoot_id:
|
if not upload_url or not upload_token or not shoot_id:
|
||||||
self.logger.error("[IMAGE_UPLOAD] 缺少必需参数: uploadUrl, token 或 shootId")
|
self.logger.error("[IMAGE_UPLOAD] 缺少必需参数: uploadUrl, token 或 shootId")
|
||||||
self.safe_enqueue({"result": "image_upload_failed", "reason": "missing_params"},
|
self.safe_enqueue({"result": "image_upload_failed", "reason": "missing_params"}, 2)
|
||||||
2)
|
|
||||||
else:
|
else:
|
||||||
self.logger.info(f"[IMAGE_UPLOAD] 收到图片上传命令,shootId: {shoot_id}")
|
self.logger.info(f"[IMAGE_UPLOAD] 收到图片上传命令,shootId: {shoot_id}")
|
||||||
# 查找文件名中包含 shoot_id 的图片文件(文件名格式:shot_{shoot_id}_*.bmp)
|
# 查找文件名中包含 shoot_id 的图片文件(文件名格式:shot_{shoot_id}_*.bmp)
|
||||||
@@ -2035,19 +1805,15 @@ class NetworkManager:
|
|||||||
reverse=True
|
reverse=True
|
||||||
)
|
)
|
||||||
target_image = os.path.join(photo_dir, matched_images[0])
|
target_image = os.path.join(photo_dir, matched_images[0])
|
||||||
self.logger.info(
|
self.logger.info(f"[IMAGE_UPLOAD] 找到匹配shootId的图片: {matched_images[0]}")
|
||||||
f"[IMAGE_UPLOAD] 找到匹配shootId的图片: {matched_images[0]}")
|
|
||||||
else:
|
else:
|
||||||
self.logger.warning(
|
self.logger.warning(f"[IMAGE_UPLOAD] 未找到包含shootId={shoot_id}的图片文件")
|
||||||
f"[IMAGE_UPLOAD] 未找到包含shootId={shoot_id}的图片文件")
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
self.logger.error(f"[IMAGE_UPLOAD] 查找图片失败: {e}")
|
self.logger.error(f"[IMAGE_UPLOAD] 查找图片失败: {e}")
|
||||||
|
|
||||||
if not target_image:
|
if not target_image:
|
||||||
self.logger.error(f"[IMAGE_UPLOAD] 未找到shootId={shoot_id}对应的图片文件")
|
self.logger.error(f"[IMAGE_UPLOAD] 未找到shootId={shoot_id}对应的图片文件")
|
||||||
self.safe_enqueue(
|
self.safe_enqueue({"result": "image_upload_failed", "reason": "no_image_found", "shootId": shoot_id}, 2)
|
||||||
{"result": "image_upload_failed", "reason": "no_image_found",
|
|
||||||
"shootId": shoot_id}, 2)
|
|
||||||
else:
|
else:
|
||||||
# 构建上传key
|
# 构建上传key
|
||||||
ext = os.path.splitext(target_image)[1].lower()
|
ext = os.path.splitext(target_image)[1].lower()
|
||||||
@@ -2055,7 +1821,8 @@ class NetworkManager:
|
|||||||
self.logger.info(f"[IMAGE_UPLOAD] 准备上传: {target_image} -> {key}")
|
self.logger.info(f"[IMAGE_UPLOAD] 准备上传: {target_image} -> {key}")
|
||||||
|
|
||||||
# 在新线程中执行上传,避免阻塞主循环
|
# 在新线程中执行上传,避免阻塞主循环
|
||||||
self._spawn_cmd_thread(
|
import _thread
|
||||||
|
_thread.start_new_thread(
|
||||||
self._upload_image_file,
|
self._upload_image_file,
|
||||||
(target_image, upload_url, upload_token, key, shoot_id, outlink)
|
(target_image, upload_url, upload_token, key, shoot_id, outlink)
|
||||||
)
|
)
|
||||||
@@ -2078,51 +1845,18 @@ class NetworkManager:
|
|||||||
# 验证必需字段
|
# 验证必需字段
|
||||||
if not upload_url or not upload_token or not key:
|
if not upload_url or not upload_token or not key:
|
||||||
self.logger.error("[LOG_UPLOAD] 缺少必需参数: uploadUrl, token 或 key")
|
self.logger.error("[LOG_UPLOAD] 缺少必需参数: uploadUrl, token 或 key")
|
||||||
self.safe_enqueue({"result": "log_upload_failed", "reason": "missing_params"},
|
self.safe_enqueue({"result": "log_upload_failed", "reason": "missing_params"}, 2)
|
||||||
2)
|
|
||||||
else:
|
else:
|
||||||
self.logger.info(f"[LOG_UPLOAD] 收到日志上传命令,key: {key}")
|
self.logger.info(f"[LOG_UPLOAD] 收到日志上传命令,key: {key}")
|
||||||
# 在新线程中执行上传,避免阻塞主循环
|
# 在新线程中执行上传,避免阻塞主循环
|
||||||
self._spawn_cmd_thread(
|
import _thread
|
||||||
|
_thread.start_new_thread(
|
||||||
self._upload_log_file_v2,
|
self._upload_log_file_v2,
|
||||||
(upload_url, upload_token, key, outlink, include_rotated, max_files,
|
(upload_url, upload_token, key, outlink, include_rotated, max_files, archive_format)
|
||||||
archive_format)
|
|
||||||
)
|
)
|
||||||
# 立即返回已入队确认
|
# 立即返回已入队确认
|
||||||
self.safe_enqueue({"result": "log_upload_queued"}, 2)
|
self.safe_enqueue({"result": "log_upload_queued"}, 2)
|
||||||
elif logged_in and msg_type == 201:
|
|
||||||
if self.logger:
|
|
||||||
self.logger.info(f"[LASER] cmd201:{body}")
|
|
||||||
raw_x = body.get("x")
|
|
||||||
raw_y = body.get("y")
|
|
||||||
try:
|
|
||||||
from laser_manager import laser_manager
|
|
||||||
ix, iy = laser_manager.set_hardcoded_laser_point(
|
|
||||||
raw_x, raw_y
|
|
||||||
)
|
|
||||||
self.safe_enqueue(
|
|
||||||
{
|
|
||||||
"cmd": 201,
|
|
||||||
"result": "laser_point_set",
|
|
||||||
"x": ix,
|
|
||||||
"y": iy,
|
|
||||||
},
|
|
||||||
2,
|
|
||||||
)
|
|
||||||
self.logger.info(
|
|
||||||
f"[LASER] cmd201 硬编码激光点=({ix}, {iy})"
|
|
||||||
)
|
|
||||||
except Exception as e:
|
|
||||||
self.logger.error(f"[LASER] cmd201 失败: {e}")
|
|
||||||
self.safe_enqueue(
|
|
||||||
{
|
|
||||||
"cmd": 201,
|
|
||||||
"result": "laser_point_set_failed",
|
|
||||||
"reason": str(e),
|
|
||||||
},
|
|
||||||
2,
|
|
||||||
)
|
|
||||||
hardware_manager.start_idle_timer()
|
|
||||||
# 处理业务指令
|
# 处理业务指令
|
||||||
elif logged_in and isinstance(body, dict):
|
elif logged_in and isinstance(body, dict):
|
||||||
inner_cmd = None
|
inner_cmd = None
|
||||||
@@ -2134,7 +1868,7 @@ class NetworkManager:
|
|||||||
if not laser_manager.calibration_active:
|
if not laser_manager.calibration_active:
|
||||||
laser_manager.turn_on_laser()
|
laser_manager.turn_on_laser()
|
||||||
time.sleep_ms(100)
|
time.sleep_ms(100)
|
||||||
hardware_manager.stop_idle_timer() # 停表
|
hardware_manager.stop_idle_timer() # 停表
|
||||||
if not config.HARDCODE_LASER_POINT:
|
if not config.HARDCODE_LASER_POINT:
|
||||||
laser_manager.start_calibration()
|
laser_manager.start_calibration()
|
||||||
self.safe_enqueue({"result": "calibrating"}, 2)
|
self.safe_enqueue({"result": "calibrating"}, 2)
|
||||||
@@ -2145,7 +1879,8 @@ class NetworkManager:
|
|||||||
from laser_manager import laser_manager
|
from laser_manager import laser_manager
|
||||||
laser_manager.turn_off_laser()
|
laser_manager.turn_off_laser()
|
||||||
laser_manager.stop_calibration()
|
laser_manager.stop_calibration()
|
||||||
hardware_manager.start_idle_timer() # 开表
|
laser_manager.stop_remote_laser_detect()
|
||||||
|
hardware_manager.start_idle_timer() # 开表
|
||||||
self.safe_enqueue({"result": "laser_off"}, 2)
|
self.safe_enqueue({"result": "laser_off"}, 2)
|
||||||
elif inner_cmd == 4: # 上报电量
|
elif inner_cmd == 4: # 上报电量
|
||||||
voltage = get_bus_voltage()
|
voltage = get_bus_voltage()
|
||||||
@@ -2153,7 +1888,6 @@ class NetworkManager:
|
|||||||
battery_data = {
|
battery_data = {
|
||||||
"battery": battery_percent,
|
"battery": battery_percent,
|
||||||
"voltage": round(float(voltage), 3),
|
"voltage": round(float(voltage), 3),
|
||||||
"netType": self.network_type,
|
|
||||||
}
|
}
|
||||||
self.safe_enqueue(battery_data, 2)
|
self.safe_enqueue(battery_data, 2)
|
||||||
self.logger.info(f"电量上报: {battery_percent}%")
|
self.logger.info(f"电量上报: {battery_percent}%")
|
||||||
@@ -2187,19 +1921,17 @@ class NetworkManager:
|
|||||||
# 只有同时满足:WiFi已连接 且 提供了WiFi凭证,才使用WiFi
|
# 只有同时满足:WiFi已连接 且 提供了WiFi凭证,才使用WiFi
|
||||||
if self.is_wifi_connected() and ssid and password:
|
if self.is_wifi_connected() and ssid and password:
|
||||||
mode = "wifi"
|
mode = "wifi"
|
||||||
self.logger.info(
|
self.logger.info("ota auto-selected: wifi (WiFi connected and credentials provided)")
|
||||||
"ota auto-selected: wifi (WiFi connected and credentials provided)")
|
|
||||||
else:
|
else:
|
||||||
mode = "4g"
|
mode = "4g"
|
||||||
self.logger.info(
|
self.logger.info("ota auto-selected: 4g (WiFi not available or no credentials)")
|
||||||
"ota auto-selected: 4g (WiFi not available or no credentials)")
|
|
||||||
|
|
||||||
hardware_manager.stop_idle_timer() # 停表,注意OTA停表之后,就没有再开表,因为OTA后面会重启,会重新开表
|
hardware_manager.stop_idle_timer() # 停表,注意OTA停表之后,就没有再开表,因为OTA后面会重启,会重新开表
|
||||||
|
|
||||||
if mode == "4g":
|
if mode == "4g":
|
||||||
ota_manager._set_ota_url(ota_url) # 记录 OTA URL,供命令7使用
|
ota_manager._set_ota_url(ota_url) # 记录 OTA URL,供命令7使用
|
||||||
ota_manager._start_update_thread()
|
ota_manager._start_update_thread()
|
||||||
self._spawn_cmd_thread(ota_manager.direct_ota_download_via_4g, (ota_url,))
|
_thread.start_new_thread(ota_manager.direct_ota_download_via_4g, (ota_url,))
|
||||||
else: # mode == "wifi"
|
else: # mode == "wifi"
|
||||||
if not ssid or not password:
|
if not ssid or not password:
|
||||||
self.logger.error("ota wifi mode requires ssid and password")
|
self.logger.error("ota wifi mode requires ssid and password")
|
||||||
@@ -2208,12 +1940,10 @@ class NetworkManager:
|
|||||||
self.logger.info(f"ssid: {ssid}")
|
self.logger.info(f"ssid: {ssid}")
|
||||||
self.logger.info(f"password: {password}")
|
self.logger.info(f"password: {password}")
|
||||||
ota_manager._start_update_thread()
|
ota_manager._start_update_thread()
|
||||||
self._spawn_cmd_thread(ota_manager.handle_wifi_and_update,
|
_thread.start_new_thread(ota_manager.handle_wifi_and_update, (ssid, password, ota_url))
|
||||||
(ssid, password, ota_url))
|
|
||||||
elif inner_cmd == 6:
|
elif inner_cmd == 6:
|
||||||
try:
|
try:
|
||||||
ip = os.popen(
|
ip = os.popen("ifconfig wlan0 2>/dev/null | grep 'inet ' | awk '{print $2}'").read().strip()
|
||||||
"ifconfig wlan0 2>/dev/null | grep 'inet ' | awk '{print $2}'").read().strip()
|
|
||||||
ip = ip if ip else "no_ip"
|
ip = ip if ip else "no_ip"
|
||||||
except:
|
except:
|
||||||
ip = "error_getting_ip"
|
ip = "error_getting_ip"
|
||||||
@@ -2221,18 +1951,91 @@ class NetworkManager:
|
|||||||
elif inner_cmd == 44: # 读 4G 本机号码(AT+CNUM)
|
elif inner_cmd == 44: # 读 4G 本机号码(AT+CNUM)
|
||||||
cnum = self.get_4g_phone_number()
|
cnum = self.get_4g_phone_number()
|
||||||
self.logger.info(f"4G 本机号码: {cnum}")
|
self.logger.info(f"4G 本机号码: {cnum}")
|
||||||
self.safe_enqueue(
|
self.safe_enqueue({"result": "cnum", "number": cnum if cnum is not None else ""}, 2)
|
||||||
{"result": "cnum", "number": cnum if cnum is not None else ""}, 2)
|
|
||||||
elif inner_cmd == 45: # 读 MCCID(AT+MCCID)
|
elif inner_cmd == 45: # 读 MCCID(AT+MCCID)
|
||||||
mccid = self.get_4g_mccid()
|
mccid = self.get_4g_mccid()
|
||||||
self.logger.info(f"4G MCCID: {mccid}")
|
self.logger.info(f"4G MCCID: {mccid}")
|
||||||
self.safe_enqueue(
|
self.safe_enqueue({"result": "mccid", "mccid": mccid if mccid is not None else ""}, 2)
|
||||||
{"result": "mccid", "mccid": mccid if mccid is not None else ""}, 2)
|
elif inner_cmd == 200: # 远程激光点识别:稳定 3s 后上报 (x,y)
|
||||||
|
from laser_manager import laser_manager
|
||||||
|
# 远程激光识别期间不能停掉心跳/空闲计时,否则会影响数据上报与连接保持
|
||||||
|
# 这里仅启动远程识别,不停止网络侧心跳。
|
||||||
|
try:
|
||||||
|
laser_manager.turn_on_laser()
|
||||||
|
if self.logger:
|
||||||
|
self.logger.info("[LASER] cmd200 已发送开激光指令")
|
||||||
|
except Exception as e:
|
||||||
|
if self.logger:
|
||||||
|
self.logger.warning(
|
||||||
|
f"[LASER] cmd200 开激光异常: {e}"
|
||||||
|
)
|
||||||
|
if not laser_manager.start_remote_laser_detect():
|
||||||
|
self.safe_enqueue(
|
||||||
|
{
|
||||||
|
"cmd": 200,
|
||||||
|
"result": "laser_detect_busy",
|
||||||
|
},
|
||||||
|
2,
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
self.safe_enqueue(
|
||||||
|
{
|
||||||
|
"cmd": 200,
|
||||||
|
"result": "laser_detect_started",
|
||||||
|
},
|
||||||
|
2,
|
||||||
|
)
|
||||||
|
elif inner_cmd == 201: # 设置硬编码激光点并结束远程识别会话
|
||||||
|
from laser_manager import laser_manager
|
||||||
|
laser_manager.stop_remote_laser_detect()
|
||||||
|
inner_data = (
|
||||||
|
data_obj.get("data", {})
|
||||||
|
if isinstance(data_obj.get("data"), dict)
|
||||||
|
else {}
|
||||||
|
)
|
||||||
|
raw_x = data_obj.get("x", inner_data.get("x"))
|
||||||
|
raw_y = data_obj.get("y", inner_data.get("y"))
|
||||||
|
try:
|
||||||
|
ix, iy = laser_manager.set_hardcoded_laser_point(
|
||||||
|
raw_x, raw_y
|
||||||
|
)
|
||||||
|
self.safe_enqueue(
|
||||||
|
{
|
||||||
|
"cmd": 201,
|
||||||
|
"result": "laser_point_set",
|
||||||
|
"x": ix,
|
||||||
|
"y": iy,
|
||||||
|
},
|
||||||
|
2,
|
||||||
|
)
|
||||||
|
self.logger.info(
|
||||||
|
f"[LASER] cmd201 硬编码激光点=({ix}, {iy})"
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
self.logger.error(f"[LASER] cmd201 失败: {e}")
|
||||||
|
self.safe_enqueue(
|
||||||
|
{
|
||||||
|
"cmd": 201,
|
||||||
|
"result": "laser_point_set_failed",
|
||||||
|
"reason": str(e),
|
||||||
|
},
|
||||||
|
2,
|
||||||
|
)
|
||||||
|
hardware_manager.start_idle_timer()
|
||||||
|
elif inner_cmd == 46: # 开关射箭原图保存
|
||||||
|
inner_data = data_obj.get("data", {}) if isinstance(data_obj, dict) else {}
|
||||||
|
enabled = True
|
||||||
|
if isinstance(inner_data, dict) and "enable" in inner_data:
|
||||||
|
enabled = bool(inner_data.get("enable"))
|
||||||
|
config.SAVE_RAW_SHOT_IMAGE_ENABLED = enabled
|
||||||
|
self.logger.info(f"[RAW_IMAGE] 射箭原图保存开关: {enabled}")
|
||||||
|
self.safe_enqueue({"result": "raw_image_save", "enabled": enabled}, 2)
|
||||||
|
hardware_manager.start_idle_timer() # 重新计时
|
||||||
elif inner_cmd == 41:
|
elif inner_cmd == 41:
|
||||||
self.logger.info(f"[TEST] 收到TCP射箭触发命令, {time.time()}")
|
self.logger.info(f"[TEST] 收到TCP射箭触发命令, {time.time()}")
|
||||||
self._manual_trigger_flag = True
|
self._manual_trigger_flag = True
|
||||||
self.safe_enqueue({"result": "trigger_ack"}, 2)
|
self.safe_enqueue({"result": "trigger_ack"}, 2)
|
||||||
hardware_manager.start_idle_timer() # 重新计时
|
hardware_manager.start_idle_timer() # 重新计时
|
||||||
elif inner_cmd == 42: # 关机命令
|
elif inner_cmd == 42: # 关机命令
|
||||||
self.logger.info("[SHUTDOWN] 收到TCP关机命令,准备关机...")
|
self.logger.info("[SHUTDOWN] 收到TCP关机命令,准备关机...")
|
||||||
self.safe_enqueue({"result": "shutdown_ack"}, 2)
|
self.safe_enqueue({"result": "shutdown_ack"}, 2)
|
||||||
@@ -2264,28 +2067,17 @@ class NetworkManager:
|
|||||||
|
|
||||||
if not upload_url:
|
if not upload_url:
|
||||||
self.logger.error("[LOG_UPLOAD] 缺少 url 参数")
|
self.logger.error("[LOG_UPLOAD] 缺少 url 参数")
|
||||||
self.safe_enqueue({"result": "log_upload_failed", "reason": "missing_url"},
|
self.safe_enqueue({"result": "log_upload_failed", "reason": "missing_url"}, 2)
|
||||||
2)
|
|
||||||
else:
|
else:
|
||||||
self.logger.info(f"[LOG_UPLOAD] 收到日志上传命令,目标URL: {upload_url}")
|
self.logger.info(f"[LOG_UPLOAD] 收到日志上传命令,目标URL: {upload_url}")
|
||||||
# 在新线程中执行上传,避免阻塞主循环
|
# 在新线程中执行上传,避免阻塞主循环
|
||||||
self._spawn_cmd_thread(
|
import _thread
|
||||||
self._upload_log_file,
|
_thread.start_new_thread(
|
||||||
(upload_url, wifi_ssid, wifi_password, include_rotated, max_files,
|
self._upload_log_file,
|
||||||
archive_format)
|
(upload_url, wifi_ssid, wifi_password, include_rotated, max_files, archive_format)
|
||||||
)
|
)
|
||||||
elif inner_cmd == 200:
|
|
||||||
self.logger.info("[LASER] cmd200 在后台线程执行检测")
|
else: # data的结构不是 dict
|
||||||
self._spawn_cmd_thread(self._cmd200_detect_laser, ())
|
|
||||||
elif inner_cmd == 300:
|
|
||||||
self.logger.info("[New Ota] cmd300 在后台线程执行OTA")
|
|
||||||
self._spawn_cmd_thread(self._cmd300_ota, (data_obj,))
|
|
||||||
elif inner_cmd == 600:
|
|
||||||
self.logger.info("[conn wifi] cmd600 在后台线程执行连接wifi: {data_obj}")
|
|
||||||
self._spawn_cmd_thread(self._cmd600_conn_wifi, (data_obj,))
|
|
||||||
elif inner_cmd == 601:
|
|
||||||
pass
|
|
||||||
else: # data的结构不是 dict
|
|
||||||
self.logger.info(f"[NET] body={body}, {time.time()}")
|
self.logger.info(f"[NET] body={body}, {time.time()}")
|
||||||
else:
|
else:
|
||||||
self.logger.info(f"[NET] 未知数据 {body}, {time.time()}")
|
self.logger.info(f"[NET] 未知数据 {body}, {time.time()}")
|
||||||
@@ -2310,28 +2102,23 @@ class NetworkManager:
|
|||||||
item_is_high = False
|
item_is_high = False
|
||||||
|
|
||||||
if item:
|
if item:
|
||||||
msg_type, data_dict = item[:2]
|
msg_type, data_dict = item
|
||||||
sent_event = item[2] if len(item) > 2 else None
|
|
||||||
pkt = self._netcore.make_packet(msg_type, data_dict)
|
pkt = self._netcore.make_packet(msg_type, data_dict)
|
||||||
if not self.tcp_send_raw(pkt):
|
if not self.tcp_send_raw(pkt):
|
||||||
# 发送失败:将消息放回队首(队列满则丢弃)
|
# 发送失败:将消息放回队首,触发重连(避免丢消息)
|
||||||
with self.get_queue_lock():
|
with self.get_queue_lock():
|
||||||
if item_is_high:
|
if item_is_high:
|
||||||
if len(self._high_send_queue) < config.MAX_SEND_QUEUE_SIZE:
|
self._high_send_queue.insert(0, item)
|
||||||
self._high_send_queue.insert(0, item)
|
|
||||||
else:
|
else:
|
||||||
if len(self._normal_send_queue) < config.MAX_SEND_QUEUE_SIZE:
|
self._normal_send_queue.insert(0, item)
|
||||||
self._normal_send_queue.insert(0, item)
|
|
||||||
self._tcp_connected = False
|
self._tcp_connected = False
|
||||||
try:
|
try:
|
||||||
self.disconnect_server()
|
self.disconnect_server()
|
||||||
except:
|
except:
|
||||||
pass
|
pass
|
||||||
break
|
break
|
||||||
if sent_event is not None:
|
|
||||||
sent_event.set()
|
|
||||||
|
|
||||||
# 发送激光校准结果
|
# 发送激光校准结果(cmd=2 等传统校准)
|
||||||
if logged_in:
|
if logged_in:
|
||||||
from laser_manager import laser_manager
|
from laser_manager import laser_manager
|
||||||
result = laser_manager.get_calibration_result()
|
result = laser_manager.get_calibration_result()
|
||||||
@@ -2339,13 +2126,27 @@ class NetworkManager:
|
|||||||
x, y = result
|
x, y = result
|
||||||
self.safe_enqueue({"result": "ok", "x": x, "y": y}, 2)
|
self.safe_enqueue({"result": "ok", "x": x, "y": y}, 2)
|
||||||
|
|
||||||
|
# 发送远程激光识别结果(cmd=200,会话持续至 cmd=201)
|
||||||
|
if logged_in:
|
||||||
|
from laser_manager import laser_manager
|
||||||
|
rd = laser_manager.get_remote_detect_result()
|
||||||
|
if rd and isinstance(rd, dict) and rd.get("status") == "ok":
|
||||||
|
self.safe_enqueue(
|
||||||
|
{
|
||||||
|
"cmd": 200,
|
||||||
|
"result": "laser_detect_ok",
|
||||||
|
"x": rd.get("x"),
|
||||||
|
"y": rd.get("y"),
|
||||||
|
},
|
||||||
|
2,
|
||||||
|
)
|
||||||
|
|
||||||
# 定期发送心跳
|
# 定期发送心跳
|
||||||
current_time = time.ticks_ms()
|
current_time = time.ticks_ms()
|
||||||
if logged_in and current_time - last_heartbeat_send_time > config.HEARTBEAT_INTERVAL * 1000:
|
if logged_in and current_time - last_heartbeat_send_time > config.HEARTBEAT_INTERVAL * 1000:
|
||||||
vol_val = get_bus_voltage()
|
vol_val = get_bus_voltage()
|
||||||
if not self.tcp_send_raw(
|
if not self.tcp_send_raw(self._netcore.make_packet(4, {"vol": vol_val, "vol_per": voltage_to_percent(vol_val)})):
|
||||||
self._netcore.make_packet(4, {"vol": vol_val, "vol_per": voltage_to_percent(vol_val)})):
|
# if not self.tcp_send_raw(self.make_packet(4, {"vol": vol_val, "vol_per": voltage_to_percent(vol_val)})):
|
||||||
# if not self.tcp_send_raw(self.make_packet(4, {"vol": vol_val, "vol_per": voltage_to_percent(vol_val)})):
|
|
||||||
send_hartbeat_fail_count += 1
|
send_hartbeat_fail_count += 1
|
||||||
# 短暂波动可能导致一次发送失败:连续失败达到阈值才重连,避免重连风暴
|
# 短暂波动可能导致一次发送失败:连续失败达到阈值才重连,避免重连风暴
|
||||||
self.logger.error(f"心跳发送失败({send_hartbeat_fail_count}/3),准备重试")
|
self.logger.error(f"心跳发送失败({send_hartbeat_fail_count}/3),准备重试")
|
||||||
@@ -2358,8 +2159,8 @@ class NetworkManager:
|
|||||||
pass
|
pass
|
||||||
break
|
break
|
||||||
else:
|
else:
|
||||||
# 不立即断开,让下一轮心跳再试
|
# 不立即断开,让下一轮心跳再试;同时缩短一点等待,提升恢复速度
|
||||||
time.sleep_ms(50)
|
time.sleep_ms(200)
|
||||||
continue
|
continue
|
||||||
else:
|
else:
|
||||||
send_hartbeat_fail_count = 0
|
send_hartbeat_fail_count = 0
|
||||||
@@ -2389,8 +2190,8 @@ class NetworkManager:
|
|||||||
self._send_event.clear()
|
self._send_event.clear()
|
||||||
|
|
||||||
self._tcp_connected = False
|
self._tcp_connected = False
|
||||||
self.logger.error("连接异常,50ms后重连...")
|
self.logger.error("连接异常,2秒后重连...")
|
||||||
time.sleep_ms(50)
|
time.sleep_ms(2000)
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
# TCP主循环的顶层异常捕获,防止线程静默退出
|
# TCP主循环的顶层异常捕获,防止线程静默退出
|
||||||
@@ -2398,50 +2199,44 @@ class NetworkManager:
|
|||||||
import traceback
|
import traceback
|
||||||
self.logger.error(traceback.format_exc())
|
self.logger.error(traceback.format_exc())
|
||||||
self._tcp_connected = False
|
self._tcp_connected = False
|
||||||
time.sleep_ms(500) # 等待5秒后重试连接
|
time.sleep_ms(5000) # 等待5秒后重试连接
|
||||||
|
|
||||||
|
|
||||||
# 创建全局单例实例
|
# 创建全局单例实例
|
||||||
network_manager = NetworkManager()
|
network_manager = NetworkManager()
|
||||||
|
|
||||||
|
|
||||||
# ==================== 向后兼容的函数接口 ====================
|
# ==================== 向后兼容的函数接口 ====================
|
||||||
|
|
||||||
def tcp_main():
|
def tcp_main():
|
||||||
"""TCP主循环(向后兼容接口)"""
|
"""TCP主循环(向后兼容接口)"""
|
||||||
return network_manager.tcp_main()
|
return network_manager.tcp_main()
|
||||||
|
|
||||||
|
|
||||||
def read_device_id():
|
def read_device_id():
|
||||||
"""读取设备ID(向后兼容接口)"""
|
"""读取设备ID(向后兼容接口)"""
|
||||||
return network_manager.read_device_id()
|
return network_manager.read_device_id()
|
||||||
|
|
||||||
|
|
||||||
def safe_enqueue(data_dict, msg_type=2, high=False):
|
def safe_enqueue(data_dict, msg_type=2, high=False):
|
||||||
"""线程安全地加入队列(向后兼容接口)"""
|
"""线程安全地加入队列(向后兼容接口)"""
|
||||||
return network_manager.safe_enqueue(data_dict, msg_type, high)
|
return network_manager.safe_enqueue(data_dict, msg_type, high)
|
||||||
|
|
||||||
|
|
||||||
def connect_server():
|
def connect_server():
|
||||||
"""连接服务器(向后兼容接口)"""
|
"""连接服务器(向后兼容接口)"""
|
||||||
return network_manager.connect_server()
|
return network_manager.connect_server()
|
||||||
|
|
||||||
|
|
||||||
def disconnet_server():
|
def disconnet_server():
|
||||||
"""断开服务器连接(向后兼容接口)"""
|
"""断开服务器连接(向后兼容接口)"""
|
||||||
return network_manager.disconnect_server()
|
return network_manager.disconnect_server()
|
||||||
|
|
||||||
|
|
||||||
def is_wifi_connected():
|
def is_wifi_connected():
|
||||||
"""检查WiFi是否已连接(向后兼容接口)"""
|
"""检查WiFi是否已连接(向后兼容接口)"""
|
||||||
return network_manager.is_wifi_connected()
|
return network_manager.is_wifi_connected()
|
||||||
|
|
||||||
|
|
||||||
def connect_wifi(ssid, password):
|
def connect_wifi(ssid, password):
|
||||||
"""连接WiFi(向后兼容接口)"""
|
"""连接WiFi(向后兼容接口)"""
|
||||||
return network_manager.connect_wifi(ssid, password)
|
return network_manager.connect_wifi(ssid, password)
|
||||||
|
|
||||||
|
|
||||||
def is_server_reachable(host, port=80, timeout=5):
|
def is_server_reachable(host, port=80, timeout=5):
|
||||||
"""检查服务器是否可达(向后兼容接口)"""
|
"""检查服务器是否可达(向后兼容接口)"""
|
||||||
return network_manager.is_server_reachable(host, port, timeout)
|
return network_manager.is_server_reachable(host, port, timeout)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
-57
@@ -1,57 +0,0 @@
|
|||||||
#!/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
|
|
||||||
@@ -5,8 +5,6 @@
|
|||||||
提供电压、电流监测和充电状态检测
|
提供电压、电流监测和充电状态检测
|
||||||
"""
|
"""
|
||||||
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
|
||||||
|
|
||||||
@@ -87,7 +85,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
|
||||||
@@ -98,13 +96,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:
|
if raw & 0x8000: # 最高位为1,表示负数(放电)
|
||||||
signed_raw = raw - 0x10000 # 转换为有符号整数
|
signed_raw = raw - 0x10000 # 转换为有符号整数
|
||||||
else:
|
else: # 最高位为0,表示正数(充电)
|
||||||
signed_raw = raw
|
signed_raw = raw
|
||||||
# 转换为毫安
|
# 转换为毫安
|
||||||
current_ma = signed_raw * current_lsb * 1000
|
current_ma = signed_raw * current_lsb * 1000
|
||||||
@@ -131,7 +129,7 @@ def is_charging(threshold_ma=10.0):
|
|||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
current = get_current()
|
current = get_current()
|
||||||
is_charge = current < -abs(float(threshold_ma))
|
is_charge = current > threshold_ma
|
||||||
return is_charge
|
return is_charge
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger = logger_manager.logger
|
logger = logger_manager.logger
|
||||||
@@ -142,115 +140,6 @@ def is_charging(threshold_ma=10.0):
|
|||||||
return False
|
return False
|
||||||
|
|
||||||
|
|
||||||
def charging_shutdown_monitor():
|
|
||||||
"""独立监测 INA226;连续确认充电后通知服务器并退出应用。"""
|
|
||||||
logger = logger_manager.logger
|
|
||||||
shutdown_enabled = bool(getattr(config, "CHARGING_SHUTDOWN_ENABLED", False))
|
|
||||||
diagnostic_enabled = bool(getattr(config, "CHARGING_DIAGNOSTIC_LOG_ENABLED", False))
|
|
||||||
if not shutdown_enabled and not diagnostic_enabled:
|
|
||||||
if logger:
|
|
||||||
logger.info("[CHARGE] 充电退出监测已禁用")
|
|
||||||
return
|
|
||||||
|
|
||||||
interval_ms = max(100, int(getattr(config, "CHARGING_CHECK_INTERVAL_MS", 5000)))
|
|
||||||
threshold_ma = float(getattr(config, "CHARGING_CURRENT_THRESHOLD_MA", 10.0))
|
|
||||||
confirm_required = max(1, int(getattr(config, "CHARGING_CONFIRM_COUNT", 2)))
|
|
||||||
confirm_count = 0
|
|
||||||
|
|
||||||
if logger:
|
|
||||||
logger.info(
|
|
||||||
f"[CHARGE] 独立监测线程启动: interval={interval_ms}ms, "
|
|
||||||
f"threshold={threshold_ma:.1f}mA, confirm={confirm_required}, "
|
|
||||||
f"shutdown={'on' if shutdown_enabled else 'off'}"
|
|
||||||
)
|
|
||||||
|
|
||||||
while True:
|
|
||||||
current_ma = get_current()
|
|
||||||
if diagnostic_enabled and logger:
|
|
||||||
voltage = get_bus_voltage()
|
|
||||||
logger.info(
|
|
||||||
f"[CHARGE-DIAG] INA226 voltage={voltage:.3f}V, "
|
|
||||||
f"current={current_ma:.1f}mA"
|
|
||||||
)
|
|
||||||
|
|
||||||
if not shutdown_enabled:
|
|
||||||
maix_time.sleep_ms(interval_ms)
|
|
||||||
continue
|
|
||||||
|
|
||||||
if current_ma < -abs(threshold_ma):
|
|
||||||
confirm_count += 1
|
|
||||||
if logger:
|
|
||||||
logger.info(
|
|
||||||
f"[CHARGE] INA226 充电电流 {current_ma:.1f}mA "
|
|
||||||
f"({confirm_count}/{confirm_required})"
|
|
||||||
)
|
|
||||||
else:
|
|
||||||
confirm_count = 0
|
|
||||||
|
|
||||||
if confirm_count >= confirm_required:
|
|
||||||
script_path = getattr(
|
|
||||||
config,
|
|
||||||
"CHARGING_EXIT_SCRIPT",
|
|
||||||
config.APP_DIR + "/charging_exit.sh",
|
|
||||||
)
|
|
||||||
if not os.path.isfile(script_path):
|
|
||||||
if logger:
|
|
||||||
logger.error(f"[CHARGE] 退出脚本不存在: {script_path}")
|
|
||||||
confirm_count = 0
|
|
||||||
else:
|
|
||||||
if logger:
|
|
||||||
logger.warning(
|
|
||||||
f"[CHARGE] 已连续确认充电,通知服务器后退出应用: current={current_ma:.1f}mA"
|
|
||||||
)
|
|
||||||
try:
|
|
||||||
from network import network_manager
|
|
||||||
|
|
||||||
notify_timeout_ms = max(
|
|
||||||
0,
|
|
||||||
int(getattr(config, "CHARGING_NOTIFY_TIMEOUT_MS", 30000)),
|
|
||||||
)
|
|
||||||
notification_sent = network_manager.safe_enqueue_and_wait(
|
|
||||||
{"poweroff": "充电中"},
|
|
||||||
2,
|
|
||||||
high=True,
|
|
||||||
timeout_ms=notify_timeout_ms,
|
|
||||||
)
|
|
||||||
if notification_sent:
|
|
||||||
if logger:
|
|
||||||
logger.info("[CHARGE] 充电状态已发送到服务器")
|
|
||||||
elif logger:
|
|
||||||
logger.warning(
|
|
||||||
f"[CHARGE] 等待服务器发送超时({notify_timeout_ms}ms),继续执行退出"
|
|
||||||
)
|
|
||||||
except Exception as e:
|
|
||||||
if logger:
|
|
||||||
logger.error(f"[CHARGE] 充电状态上报失败,继续执行退出: {e}")
|
|
||||||
try:
|
|
||||||
from laser_manager import laser_manager
|
|
||||||
|
|
||||||
laser_manager.turn_off_laser()
|
|
||||||
if logger:
|
|
||||||
logger.info("[CHARGE] 激光关闭命令已发送")
|
|
||||||
except Exception as e:
|
|
||||||
if logger:
|
|
||||||
logger.error(f"[CHARGE] Python 关闭激光失败,交由退出脚本兜底: {e}")
|
|
||||||
try:
|
|
||||||
subprocess.Popen([
|
|
||||||
"/bin/sh",
|
|
||||||
script_path,
|
|
||||||
str(os.getpid()),
|
|
||||||
str(getattr(config, "DISTANCE_SERIAL_DEVICE", "/dev/ttyS1")),
|
|
||||||
str(getattr(config, "DISTANCE_SERIAL_BAUDRATE", 9600)),
|
|
||||||
])
|
|
||||||
return
|
|
||||||
except Exception as e:
|
|
||||||
if logger:
|
|
||||||
logger.error(f"[CHARGE] 调用退出脚本失败: {e}")
|
|
||||||
confirm_count = 0
|
|
||||||
|
|
||||||
maix_time.sleep_ms(interval_ms)
|
|
||||||
|
|
||||||
|
|
||||||
def voltage_to_percent(voltage):
|
def voltage_to_percent(voltage):
|
||||||
"""
|
"""
|
||||||
根据电压估算电池百分比(高密度查表插值 + 滤波)。
|
根据电压估算电池百分比(高密度查表插值 + 滤波)。
|
||||||
|
|||||||
+157
-14
@@ -8,7 +8,7 @@ from laser_manager import laser_manager
|
|||||||
from logger_manager import logger_manager
|
from logger_manager import logger_manager
|
||||||
from network import network_manager
|
from network import network_manager
|
||||||
from triangle_target import load_camera_from_xml, load_triangle_positions, try_triangle_scoring
|
from triangle_target import load_camera_from_xml, load_triangle_positions, try_triangle_scoring
|
||||||
from vision import estimate_distance, detect_circle_v3, enqueue_save_shot
|
from vision import estimate_distance, detect_circle_v3, enqueue_save_shot, enqueue_save_raw_shot
|
||||||
from maix import image, time
|
from maix import image, time
|
||||||
|
|
||||||
# 缓存相机标定与三角形位置,避免每次射箭重复读磁盘
|
# 缓存相机标定与三角形位置,避免每次射箭重复读磁盘
|
||||||
@@ -58,6 +58,7 @@ def analyze_shot(frame, laser_point=None):
|
|||||||
# ── Step 1: 确定激光点 ────────────────────────────────────────────────────
|
# ── Step 1: 确定激光点 ────────────────────────────────────────────────────
|
||||||
laser_point_method = None
|
laser_point_method = None
|
||||||
distance_m_first = None
|
distance_m_first = None
|
||||||
|
best_radius1_temp = None
|
||||||
|
|
||||||
if config.HARDCODE_LASER_POINT:
|
if config.HARDCODE_LASER_POINT:
|
||||||
laser_point = laser_manager.laser_point
|
laser_point = laser_manager.laser_point
|
||||||
@@ -102,9 +103,22 @@ def analyze_shot(frame, laser_point=None):
|
|||||||
r_img, center, radius, method, best_radius1, ellipse_params = cdata
|
r_img, center, radius, method, best_radius1, ellipse_params = cdata
|
||||||
dx, dy = None, None
|
dx, dy = None, None
|
||||||
d_m = distance_m_first
|
d_m = distance_m_first
|
||||||
|
tri_h = None
|
||||||
if center and radius:
|
if center and radius:
|
||||||
dx, dy = laser_manager.compute_laser_position(center, (x, y), radius, method)
|
dx, dy = laser_manager.compute_laser_position(center, (x, y), radius, method)
|
||||||
d_m = estimate_distance(best_radius1) if best_radius1 else distance_m_first
|
d_m = estimate_distance(best_radius1) if best_radius1 else distance_m_first
|
||||||
|
try:
|
||||||
|
import numpy as _np
|
||||||
|
px_per_cm = float(radius) / 10.0
|
||||||
|
if px_per_cm > 1e-6:
|
||||||
|
cxp, cyp = float(center[0]), float(center[1])
|
||||||
|
tri_h = _np.array([
|
||||||
|
[1.0 / px_per_cm, 0.0, -cxp / px_per_cm],
|
||||||
|
[0.0, 1.0 / px_per_cm, -cyp / px_per_cm],
|
||||||
|
[0.0, 0.0, 1.0],
|
||||||
|
], dtype=float)
|
||||||
|
except Exception:
|
||||||
|
tri_h = None
|
||||||
out = {
|
out = {
|
||||||
"success": True,
|
"success": True,
|
||||||
"result_img": r_img,
|
"result_img": r_img,
|
||||||
@@ -114,6 +128,7 @@ def analyze_shot(frame, laser_point=None):
|
|||||||
"laser_point": laser_point, "laser_point_method": laser_point_method,
|
"laser_point": laser_point, "laser_point_method": laser_point_method,
|
||||||
"offset_method": "yellow_ellipse" if ellipse_params else "yellow_circle",
|
"offset_method": "yellow_ellipse" if ellipse_params else "yellow_circle",
|
||||||
"distance_method": "yellow_radius",
|
"distance_method": "yellow_radius",
|
||||||
|
"tri_homography": tri_h,
|
||||||
}
|
}
|
||||||
if yolo_roi_xyxy is not None:
|
if yolo_roi_xyxy is not None:
|
||||||
out["yolo_roi_xyxy"] = yolo_roi_xyxy
|
out["yolo_roi_xyxy"] = yolo_roi_xyxy
|
||||||
@@ -129,8 +144,10 @@ def analyze_shot(frame, laser_point=None):
|
|||||||
roi_xyxy = None
|
roi_xyxy = None
|
||||||
yolo_ring_ms = 0.0
|
yolo_ring_ms = 0.0
|
||||||
yolo_black_ms = 0.0
|
yolo_black_ms = 0.0
|
||||||
|
_timing_on = bool(getattr(config, "ARCHERY_TIMING_ENABLE", True))
|
||||||
|
_sample_on = bool(getattr(config, "TRIANGLE_SAMPLE_ENABLE", False))
|
||||||
if getattr(config, "TRIANGLE_YOLO_ROI_ENABLE", False):
|
if getattr(config, "TRIANGLE_YOLO_ROI_ENABLE", False):
|
||||||
_t_yolo_ring = time_std.perf_counter()
|
_t_yolo_ring = time_std.perf_counter() if _timing_on else None
|
||||||
try:
|
try:
|
||||||
from target_roi_yolo import try_get_triangle_roi_from_yolo
|
from target_roi_yolo import try_get_triangle_roi_from_yolo
|
||||||
roi_xyxy = try_get_triangle_roi_from_yolo(
|
roi_xyxy = try_get_triangle_roi_from_yolo(
|
||||||
@@ -140,7 +157,8 @@ def analyze_shot(frame, laser_point=None):
|
|||||||
if logger:
|
if logger:
|
||||||
logger.warning(f"[YOLO-ROI] {e}")
|
logger.warning(f"[YOLO-ROI] {e}")
|
||||||
finally:
|
finally:
|
||||||
yolo_ring_ms = (time_std.perf_counter() - _t_yolo_ring) * 1000.0
|
if _timing_on and _t_yolo_ring is not None:
|
||||||
|
yolo_ring_ms = (time_std.perf_counter() - _t_yolo_ring) * 1000.0
|
||||||
|
|
||||||
_loc_mode = str(
|
_loc_mode = str(
|
||||||
getattr(config, "TRIANGLE_BLACK_TRIANGLE_LOCATE_MODE", "yolo")
|
getattr(config, "TRIANGLE_BLACK_TRIANGLE_LOCATE_MODE", "yolo")
|
||||||
@@ -155,7 +173,7 @@ def analyze_shot(frame, laser_point=None):
|
|||||||
and roi_xyxy is not None
|
and roi_xyxy is not None
|
||||||
)
|
)
|
||||||
if _run_stage2_black_yolo:
|
if _run_stage2_black_yolo:
|
||||||
_t_yolo_black = time_std.perf_counter()
|
_t_yolo_black = time_std.perf_counter() if _timing_on else None
|
||||||
try:
|
try:
|
||||||
from target_roi_yolo import try_black_triangle_boxes_work
|
from target_roi_yolo import try_black_triangle_boxes_work
|
||||||
|
|
||||||
@@ -166,7 +184,8 @@ def analyze_shot(frame, laser_point=None):
|
|||||||
if logger:
|
if logger:
|
||||||
logger.warning(f"[YOLO-BLACK] {e}")
|
logger.warning(f"[YOLO-BLACK] {e}")
|
||||||
finally:
|
finally:
|
||||||
yolo_black_ms = (time_std.perf_counter() - _t_yolo_black) * 1000.0
|
if _timing_on and _t_yolo_black is not None:
|
||||||
|
yolo_black_ms = (time_std.perf_counter() - _t_yolo_black) * 1000.0
|
||||||
elif (
|
elif (
|
||||||
logger
|
logger
|
||||||
and _loc_mode == "traditional"
|
and _loc_mode == "traditional"
|
||||||
@@ -184,7 +203,7 @@ def analyze_shot(frame, laser_point=None):
|
|||||||
try:
|
try:
|
||||||
logger.info(f"[TRI] begin {datetime.now()}")
|
logger.info(f"[TRI] begin {datetime.now()}")
|
||||||
logger.info(f"[TRI] K: {K}, dist: {dist_coef}, pos: {pos}, {datetime.now()}")
|
logger.info(f"[TRI] K: {K}, dist: {dist_coef}, pos: {pos}, {datetime.now()}")
|
||||||
_t_wall_try = time_std.perf_counter()
|
_t_wall_try = time_std.perf_counter() if _timing_on else None
|
||||||
tri = try_triangle_scoring(
|
tri = try_triangle_scoring(
|
||||||
img_cv, (x, y), pos, K, dist_coef,
|
img_cv, (x, y), pos, K, dist_coef,
|
||||||
size_range=getattr(config, "TRIANGLE_SIZE_RANGE", (8, 500)),
|
size_range=getattr(config, "TRIANGLE_SIZE_RANGE", (8, 500)),
|
||||||
@@ -193,8 +212,8 @@ def analyze_shot(frame, laser_point=None):
|
|||||||
yolo_ring_ms=yolo_ring_ms,
|
yolo_ring_ms=yolo_ring_ms,
|
||||||
yolo_black_ms=yolo_black_ms,
|
yolo_black_ms=yolo_black_ms,
|
||||||
)
|
)
|
||||||
_wall_try_ms = (time_std.perf_counter() - _t_wall_try) * 1000.0
|
_wall_try_ms = (time_std.perf_counter() - _t_wall_try) * 1000.0 if _timing_on else 0.0
|
||||||
if logger and bool(getattr(config, "TRIANGLE_LOG_E2E_TIMING", True)):
|
if logger and bool(getattr(config, "TRIANGLE_LOG_E2E_TIMING", True)) and _timing_on:
|
||||||
_e2e = float(yolo_ring_ms) + float(yolo_black_ms) + float(_wall_try_ms)
|
_e2e = float(yolo_ring_ms) + float(yolo_black_ms) + float(_wall_try_ms)
|
||||||
logger.info(
|
logger.info(
|
||||||
f"[TRI] timing_e2e_triangle_ms={_e2e:.1f} "
|
f"[TRI] timing_e2e_triangle_ms={_e2e:.1f} "
|
||||||
@@ -280,6 +299,16 @@ def analyze_shot(frame, laser_point=None):
|
|||||||
"tri_markers_completed": tri.get("markers_completed", []),
|
"tri_markers_completed": tri.get("markers_completed", []),
|
||||||
"tri_homography": tri.get("homography"),
|
"tri_homography": tri.get("homography"),
|
||||||
}
|
}
|
||||||
|
try:
|
||||||
|
import numpy as _np
|
||||||
|
_H = tri.get("homography")
|
||||||
|
if _H is not None and _np.all(_np.isfinite(_H)):
|
||||||
|
_H_inv = _np.linalg.inv(_H)
|
||||||
|
_pt = _np.array([[[0.0, 0.0]]], dtype=_np.float32)
|
||||||
|
_center_pt = cv2.perspectiveTransform(_pt, _H_inv)[0][0]
|
||||||
|
out["tri_center_px"] = [float(_center_pt[0]), float(_center_pt[1])]
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
if yolo_roi_xyxy is not None:
|
if yolo_roi_xyxy is not None:
|
||||||
out["yolo_roi_xyxy"] = yolo_roi_xyxy
|
out["yolo_roi_xyxy"] = yolo_roi_xyxy
|
||||||
return out
|
return out
|
||||||
@@ -318,10 +347,21 @@ def process_shot(adc_val):
|
|||||||
:return: None
|
:return: None
|
||||||
"""
|
"""
|
||||||
logger = logger_manager.logger
|
logger = logger_manager.logger
|
||||||
|
_timing_on = bool(getattr(config, "ARCHERY_TIMING_ENABLE", True))
|
||||||
|
|
||||||
try:
|
try:
|
||||||
frame = camera_manager.read_frame()
|
|
||||||
network_manager.safe_enqueue({"shoot_event": "start"}, msg_type=2, high=True)
|
network_manager.safe_enqueue({"shoot_event": "start"}, msg_type=2, high=True)
|
||||||
|
frame = camera_manager.read_frame()
|
||||||
|
|
||||||
|
from shot_id_generator import shot_id_generator
|
||||||
|
shot_id = shot_id_generator.generate_id()
|
||||||
|
|
||||||
|
if getattr(config, "SAVE_RAW_SHOT_IMAGE_ENABLED", False):
|
||||||
|
enqueue_save_raw_shot(
|
||||||
|
frame,
|
||||||
|
shot_id=shot_id,
|
||||||
|
photo_dir=config.PHOTO_DIR if config.SAVE_IMAGE_ENABLED else None,
|
||||||
|
)
|
||||||
|
|
||||||
# 调用算法分析
|
# 调用算法分析
|
||||||
analysis_result = analyze_shot(frame)
|
analysis_result = analyze_shot(frame)
|
||||||
@@ -356,6 +396,107 @@ def process_shot(adc_val):
|
|||||||
)
|
)
|
||||||
x, y = laser_point
|
x, y = laser_point
|
||||||
|
|
||||||
|
# 物方采样调试(config.TRIANGLE_SAMPLE_ENABLE):靶心为原点,取两个对称点判断黑白来区分 40/20 标靶
|
||||||
|
# 逻辑:若两个采样点 RGB 均 < 阈值 → 全黑 → 40cm 标靶;否则 → 20cm 标靶
|
||||||
|
sample_target_type = None
|
||||||
|
_t_sample = time_std.perf_counter() if _timing_on else None
|
||||||
|
_t_sample_ms = 0.0
|
||||||
|
sample_points = []
|
||||||
|
sample_patch_half = 2
|
||||||
|
if bool(getattr(config, "TRIANGLE_SAMPLE_ENABLE", False)):
|
||||||
|
sample_obj_radius_cm = float(getattr(config, "TRIANGLE_SAMPLE_RADIUS_CM", 15.0))
|
||||||
|
sample_obj_angles_deg = (0, 180) # 只取两个对称点:+X 和 -X
|
||||||
|
sample_patch_half = int(getattr(config, "TRIANGLE_SAMPLE_PATCH_HALF_PX", 2))
|
||||||
|
sample_black_thresh = float(getattr(config, "TRIANGLE_SAMPLE_BLACK_THRESH", 30.0))
|
||||||
|
try:
|
||||||
|
import math as _math
|
||||||
|
import numpy as _np
|
||||||
|
import cv2 as _cv2
|
||||||
|
|
||||||
|
if tri_homography is not None:
|
||||||
|
_H_inv = _np.linalg.inv(tri_homography)
|
||||||
|
for _ang in sample_obj_angles_deg:
|
||||||
|
_rad = _math.radians(float(_ang))
|
||||||
|
_pt_obj = _np.array([
|
||||||
|
[[sample_obj_radius_cm * _math.cos(_rad), sample_obj_radius_cm * _math.sin(_rad)]]
|
||||||
|
], dtype=_np.float32)
|
||||||
|
_pt_img = _cv2.perspectiveTransform(_pt_obj, _H_inv)[0][0]
|
||||||
|
_px, _py = float(_pt_img[0]), float(_pt_img[1])
|
||||||
|
sample_points.append({
|
||||||
|
"angle_deg": float(_ang),
|
||||||
|
"obj_cm": (float(sample_obj_radius_cm * _math.cos(_rad)), float(sample_obj_radius_cm * _math.sin(_rad))),
|
||||||
|
"img_px": (int(round(_px)), int(round(_py))),
|
||||||
|
})
|
||||||
|
elif center and radius:
|
||||||
|
_px_per_cm = float(radius) / 10.0
|
||||||
|
for _ang in sample_obj_angles_deg:
|
||||||
|
_rad = _math.radians(float(_ang))
|
||||||
|
_px = float(center[0]) + sample_obj_radius_cm * _math.cos(_rad) * _px_per_cm
|
||||||
|
_py = float(center[1]) + sample_obj_radius_cm * _math.sin(_rad) * _px_per_cm
|
||||||
|
sample_points.append({
|
||||||
|
"angle_deg": float(_ang),
|
||||||
|
"obj_cm": (float(sample_obj_radius_cm * _math.cos(_rad)), float(sample_obj_radius_cm * _math.sin(_rad))),
|
||||||
|
"img_px": (int(round(_px)), int(round(_py))),
|
||||||
|
})
|
||||||
|
|
||||||
|
# 取样后立即读像素并判断黑白:三角成功用 H_inv;三角失败但圆心成功用 center/radius 近似物方半径
|
||||||
|
_all_black = False
|
||||||
|
_sample_infos = []
|
||||||
|
if sample_points:
|
||||||
|
_img_cv_for_sample = image.image2cv(result_img, False, False)
|
||||||
|
_all_black = True
|
||||||
|
for _sp in sample_points:
|
||||||
|
_sx, _sy = _sp["img_px"]
|
||||||
|
_hh = max(1, sample_patch_half)
|
||||||
|
_patch = []
|
||||||
|
for _yy in range(_sy - _hh, _sy + _hh + 1):
|
||||||
|
if _yy < 0 or _yy >= _img_cv_for_sample.shape[0]:
|
||||||
|
continue
|
||||||
|
for _xx in range(_sx - _hh, _sx + _hh + 1):
|
||||||
|
if _xx < 0 or _xx >= _img_cv_for_sample.shape[1]:
|
||||||
|
continue
|
||||||
|
_patch.append(_img_cv_for_sample[_yy, _xx].astype(float))
|
||||||
|
if _patch:
|
||||||
|
_mean_rgb = _np.mean(_patch, axis=0)
|
||||||
|
_is_black = bool(_mean_rgb[0] < sample_black_thresh
|
||||||
|
and _mean_rgb[1] < sample_black_thresh
|
||||||
|
and _mean_rgb[2] < sample_black_thresh)
|
||||||
|
if not _is_black:
|
||||||
|
_all_black = False
|
||||||
|
_sample_infos.append(
|
||||||
|
f"{int(_sp['angle_deg'])}°@{_sx},{_sy} rgb=({int(_mean_rgb[0])},{int(_mean_rgb[1])},{int(_mean_rgb[2])})"
|
||||||
|
)
|
||||||
|
sample_target_type = "40cm_black" if _all_black else "20cm"
|
||||||
|
if _sample_infos:
|
||||||
|
logger.info("[采样] " + " | ".join(_sample_infos) + f" → {sample_target_type}")
|
||||||
|
except Exception as _e_sample:
|
||||||
|
sample_points = []
|
||||||
|
if logger:
|
||||||
|
logger.warning(f"[采样] 标靶类型判断失败: {_e_sample}")
|
||||||
|
if _timing_on and _t_sample is not None:
|
||||||
|
_t_sample_ms = (time_std.perf_counter() - _t_sample) * 1000.0
|
||||||
|
|
||||||
|
# 采样提前完成后,先确定靶型对应的物理半径,供后续距离/偏移/上报使用。
|
||||||
|
# 40cm_black 表示直径40cm,半径20cm;20cm 表示直径20cm,半径10cm。
|
||||||
|
target_radius_cm = 20.0 if sample_target_type == "40cm_black" else (10.0 if sample_target_type == "20cm" else 20.0)
|
||||||
|
target_type_value = 40 if sample_target_type == "40cm_black" else (20 if sample_target_type == "20cm" else None)
|
||||||
|
|
||||||
|
# 圆心分支原算法默认按40cm靶半径20cm换算;若采样判定为20cm靶,在上报前修正距离和偏移。
|
||||||
|
# 三角分支使用 triangle_positions.json 的物方坐标,不在这里二次缩放,避免影响三角单应性结果。
|
||||||
|
if sample_target_type == "20cm" and center and radius and not tri_markers:
|
||||||
|
try:
|
||||||
|
distance_m = (target_radius_cm * config.FOCAL_LENGTH_PIX) / float(radius) / 100.0
|
||||||
|
_scale = target_radius_cm / 20.0
|
||||||
|
if dx is not None:
|
||||||
|
dx = float(dx) * _scale
|
||||||
|
if dy is not None:
|
||||||
|
dy = float(dy) * _scale
|
||||||
|
if logger:
|
||||||
|
logger.info(f"[采样] 20cm靶修正圆心测距/偏移: distance={distance_m:.2f}m scale={_scale:.2f}")
|
||||||
|
except Exception as _e_fix:
|
||||||
|
if logger:
|
||||||
|
logger.warning(f"[采样] 20cm靶修正失败: {_e_fix}")
|
||||||
|
|
||||||
# 三角形路径成功时 center/radius 为空是正常的;此时用 triangle 方法名用于保存文件名与上报字段 m
|
# 三角形路径成功时 center/radius 为空是正常的;此时用 triangle 方法名用于保存文件名与上报字段 m
|
||||||
if (not method) and tri_markers:
|
if (not method) and tri_markers:
|
||||||
method = "triangle_homography"
|
method = "triangle_homography"
|
||||||
@@ -366,10 +507,6 @@ def process_shot(adc_val):
|
|||||||
if dx is None and dy is None and logger:
|
if dx is None and dy is None and logger:
|
||||||
logger.warning("[MAIN] 未检测到偏移量(三角形与圆形均失败),但会保存图像")
|
logger.warning("[MAIN] 未检测到偏移量(三角形与圆形均失败),但会保存图像")
|
||||||
|
|
||||||
# 生成射箭ID
|
|
||||||
from shot_id_generator import shot_id_generator
|
|
||||||
shot_id = shot_id_generator.generate_id()
|
|
||||||
|
|
||||||
if logger:
|
if logger:
|
||||||
logger.info(f"[MAIN] 射箭ID: {shot_id}")
|
logger.info(f"[MAIN] 射箭ID: {shot_id}")
|
||||||
|
|
||||||
@@ -386,7 +523,7 @@ def process_shot(adc_val):
|
|||||||
"shot_id": shot_id,
|
"shot_id": shot_id,
|
||||||
"x": srv_x,
|
"x": srv_x,
|
||||||
"y": srv_y,
|
"y": srv_y,
|
||||||
"r": 20.0, # 保留字段(服务端当前忽略,物理外环半径 cm)
|
"r": target_radius_cm, # 物理靶半径 cm:40cm靶=20,20cm靶=10
|
||||||
"d": round((distance_m or 0.0) * 100),
|
"d": round((distance_m or 0.0) * 100),
|
||||||
"d_laser": round((laser_distance_m or 0.0) * 100),
|
"d_laser": round((laser_distance_m or 0.0) * 100),
|
||||||
"d_laser_quality": laser_signal_quality,
|
"d_laser_quality": laser_signal_quality,
|
||||||
@@ -397,6 +534,7 @@ def process_shot(adc_val):
|
|||||||
"target_y": float(y),
|
"target_y": float(y),
|
||||||
"offset_method": offset_method,
|
"offset_method": offset_method,
|
||||||
"distance_method": distance_method,
|
"distance_method": distance_method,
|
||||||
|
"target_type": target_type_value,
|
||||||
}
|
}
|
||||||
|
|
||||||
if ellipse_params:
|
if ellipse_params:
|
||||||
@@ -471,6 +609,11 @@ def process_shot(adc_val):
|
|||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
# 物方采样标靶类型判断耗时(合并在上面采样块内,单独统计)
|
||||||
|
if _timing_on and bool(getattr(config, "TRIANGLE_SAMPLE_ENABLE", False)) and sample_target_type is not None:
|
||||||
|
logger.info(f"[采样] 标靶类型: {sample_target_type} 耗时: {_t_sample_ms:.2f}ms")
|
||||||
|
|
||||||
|
|
||||||
# 叠加信息:落点-圆心距离 / 相机-靶距离等
|
# 叠加信息:落点-圆心距离 / 相机-靶距离等
|
||||||
try:
|
try:
|
||||||
import math as _math
|
import math as _math
|
||||||
|
|||||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,403 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
"""
|
||||||
|
实时摄像头预览:叠加与射箭存图相同的算法标注(YOLO ROI、三角/圆心、激光十字等),默认不写盘。
|
||||||
|
|
||||||
|
在 MaixCAM 上从项目根目录运行:
|
||||||
|
python3 test/test_algo_preview_live.py
|
||||||
|
python3 test/test_algo_preview_live.py --interval 1.5
|
||||||
|
python3 test/test_algo_preview_live.py --every-frame
|
||||||
|
|
||||||
|
说明:
|
||||||
|
- 完整算法走 shoot_manager.analyze_shot(与 process_shot 一致,含 YOLO + 三角/圆心)。
|
||||||
|
- 画面标注对齐 process_shot 存图前绘制 + vision._draw_yolo_roi_on_rgb_numpy / 圆心存图线。
|
||||||
|
- 预览模式会关闭 Stage2 裁切 JPEG 落盘,避免写满 /root/phot。
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import math
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
import threading
|
||||||
|
import time
|
||||||
|
|
||||||
|
_ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||||||
|
if _ROOT not in sys.path:
|
||||||
|
sys.path.insert(0, _ROOT)
|
||||||
|
|
||||||
|
import cv2
|
||||||
|
import numpy as np
|
||||||
|
from maix import image, time as maix_time
|
||||||
|
|
||||||
|
import config
|
||||||
|
from camera_manager import camera_manager
|
||||||
|
from laser_manager import laser_manager
|
||||||
|
from shoot_manager import analyze_shot, preload_triangle_calib
|
||||||
|
from target_roi_yolo import preload_yolo_detector
|
||||||
|
from vision import _draw_yolo_roi_on_rgb_numpy
|
||||||
|
|
||||||
|
|
||||||
|
def _copy_maix_frame(frame):
|
||||||
|
"""相机下一帧可能复用缓冲区,异步分析前先复制。"""
|
||||||
|
img_cv = image.image2cv(frame, False, False)
|
||||||
|
return image.cv2image(np.ascontiguousarray(img_cv), False, False)
|
||||||
|
|
||||||
|
|
||||||
|
def _patch_preview_config():
|
||||||
|
"""预览不写调试 JPEG,避免刷屏占存储。"""
|
||||||
|
config.TRIANGLE_BLACK_YOLO_SAVE_ROI_CROP = False
|
||||||
|
config.TRIANGLE_SAVE_DEBUG_IMAGE = False
|
||||||
|
|
||||||
|
|
||||||
|
def _annotate_like_saved_shot(analysis: dict):
|
||||||
|
"""
|
||||||
|
将 analyze_shot 结果绘制成与 process_shot -> enqueue_save_shot 存盘前一致的 Maix 图。
|
||||||
|
"""
|
||||||
|
result_img = analysis.get("result_img")
|
||||||
|
if result_img is None:
|
||||||
|
return None
|
||||||
|
|
||||||
|
center = analysis.get("center")
|
||||||
|
radius = analysis.get("radius")
|
||||||
|
method = analysis.get("method")
|
||||||
|
ellipse_params = analysis.get("ellipse_params")
|
||||||
|
laser_point = analysis.get("laser_point")
|
||||||
|
dx = analysis.get("dx")
|
||||||
|
dy = analysis.get("dy")
|
||||||
|
distance_m = analysis.get("distance_m")
|
||||||
|
offset_method = analysis.get("offset_method", "")
|
||||||
|
distance_method = analysis.get("distance_method", "")
|
||||||
|
tri_markers = analysis.get("tri_markers") or []
|
||||||
|
tri_markers_completed = analysis.get("tri_markers_completed") or []
|
||||||
|
tri_homography = analysis.get("tri_homography")
|
||||||
|
yolo_roi_xyxy = analysis.get("yolo_roi_xyxy")
|
||||||
|
|
||||||
|
if laser_point is None:
|
||||||
|
return result_img
|
||||||
|
|
||||||
|
x, y = laser_point
|
||||||
|
draw_yolo_roi = (
|
||||||
|
yolo_roi_xyxy is not None
|
||||||
|
and getattr(config, "TRIANGLE_YOLO_DRAW_ROI_ON_SHOT", True)
|
||||||
|
)
|
||||||
|
|
||||||
|
if tri_markers:
|
||||||
|
img_cv = image.image2cv(result_img, False, False).copy()
|
||||||
|
|
||||||
|
if draw_yolo_roi:
|
||||||
|
_draw_yolo_roi_on_rgb_numpy(img_cv, yolo_roi_xyxy)
|
||||||
|
|
||||||
|
for m in tri_markers:
|
||||||
|
corners = np.array(m["corners"], dtype=np.int32)
|
||||||
|
cv2.polylines(img_cv, [corners], True, (0, 255, 0), 2)
|
||||||
|
cx, cy = int(m["center"][0]), int(m["center"][1])
|
||||||
|
cv2.circle(img_cv, (cx, cy), 4, (0, 0, 255), -1)
|
||||||
|
cv2.putText(
|
||||||
|
img_cv,
|
||||||
|
f"T{m['id']}",
|
||||||
|
(cx - 18, cy - 12),
|
||||||
|
cv2.FONT_HERSHEY_SIMPLEX,
|
||||||
|
0.55,
|
||||||
|
(0, 255, 0),
|
||||||
|
1,
|
||||||
|
)
|
||||||
|
|
||||||
|
for m in tri_markers_completed:
|
||||||
|
if not m.get("is_virtual"):
|
||||||
|
continue
|
||||||
|
cx, cy = int(m["center"][0]), int(m["center"][1])
|
||||||
|
cv2.circle(img_cv, (cx, cy), 6, (255, 0, 255), 2)
|
||||||
|
cv2.putText(
|
||||||
|
img_cv,
|
||||||
|
f"VT{m['id']}",
|
||||||
|
(cx - 22, cy - 12),
|
||||||
|
cv2.FONT_HERSHEY_SIMPLEX,
|
||||||
|
0.55,
|
||||||
|
(255, 0, 255),
|
||||||
|
1,
|
||||||
|
)
|
||||||
|
|
||||||
|
if tri_homography is not None:
|
||||||
|
try:
|
||||||
|
H_inv = np.linalg.inv(tri_homography)
|
||||||
|
c_img = cv2.perspectiveTransform(
|
||||||
|
np.array([[[0.0, 0.0]]], dtype=np.float32), H_inv
|
||||||
|
)[0][0]
|
||||||
|
ocx, ocy = int(c_img[0]), int(c_img[1])
|
||||||
|
cv2.circle(img_cv, (ocx, ocy), 5, (0, 0, 255), -1)
|
||||||
|
cv2.circle(img_cv, (ocx, ocy), 9, (0, 0, 255), 1)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
lines = []
|
||||||
|
if dx is not None and dy is not None:
|
||||||
|
r_cm = math.hypot(float(dx), float(dy))
|
||||||
|
lines.append(f"offset=({float(dx):.2f},{float(dy):.2f})cm |r|={r_cm:.2f}cm")
|
||||||
|
if distance_m is not None:
|
||||||
|
lines.append(f"cam_dist={float(distance_m):.2f}m ({distance_method})")
|
||||||
|
if method:
|
||||||
|
lines.append(f"method={method} ({offset_method})")
|
||||||
|
y0 = 22
|
||||||
|
for i, t in enumerate(lines):
|
||||||
|
cv2.putText(
|
||||||
|
img_cv,
|
||||||
|
t,
|
||||||
|
(10, y0 + i * 18),
|
||||||
|
cv2.FONT_HERSHEY_SIMPLEX,
|
||||||
|
0.5,
|
||||||
|
(0, 255, 0),
|
||||||
|
1,
|
||||||
|
)
|
||||||
|
|
||||||
|
out = image.cv2image(img_cv, False, False)
|
||||||
|
else:
|
||||||
|
img_cv = image.image2cv(result_img, False, False).copy()
|
||||||
|
if draw_yolo_roi:
|
||||||
|
_draw_yolo_roi_on_rgb_numpy(img_cv, yolo_roi_xyxy)
|
||||||
|
|
||||||
|
if center and radius:
|
||||||
|
cx, cy = center
|
||||||
|
if ellipse_params:
|
||||||
|
(ell_center, (width, height), angle) = ellipse_params
|
||||||
|
cx_ell, cy_ell = int(ell_center[0]), int(ell_center[1])
|
||||||
|
cv2.ellipse(
|
||||||
|
img_cv,
|
||||||
|
(cx_ell, cy_ell),
|
||||||
|
(int(width / 2), int(height / 2)),
|
||||||
|
angle,
|
||||||
|
0,
|
||||||
|
360,
|
||||||
|
(0, 255, 0),
|
||||||
|
2,
|
||||||
|
)
|
||||||
|
cv2.circle(img_cv, (cx_ell, cy_ell), 3, (255, 0, 0), -1)
|
||||||
|
minor_length = min(width, height) / 2
|
||||||
|
minor_angle = angle + 90 if width >= height else angle
|
||||||
|
minor_angle_rad = math.radians(minor_angle)
|
||||||
|
dx_minor = minor_length * math.cos(minor_angle_rad)
|
||||||
|
dy_minor = minor_length * math.sin(minor_angle_rad)
|
||||||
|
pt1 = (int(cx_ell - dx_minor), int(cy_ell - dy_minor))
|
||||||
|
pt2 = (int(cx_ell + dx_minor), int(cy_ell + dy_minor))
|
||||||
|
cv2.line(img_cv, pt1, pt2, (0, 0, 255), 2)
|
||||||
|
else:
|
||||||
|
cv2.circle(img_cv, (int(cx), int(cy)), int(radius), (0, 0, 255), 2)
|
||||||
|
cv2.circle(img_cv, (int(cx), int(cy)), 2, (0, 0, 255), -1)
|
||||||
|
cv2.line(img_cv, (int(x), int(y)), (int(cx), int(cy)), (255, 255, 0), 1)
|
||||||
|
|
||||||
|
lines = []
|
||||||
|
if dx is not None and dy is not None:
|
||||||
|
lines.append(f"offset=({float(dx):.2f},{float(dy):.2f})cm")
|
||||||
|
if distance_m is not None:
|
||||||
|
lines.append(f"dist={float(distance_m):.2f}m ({distance_method})")
|
||||||
|
if method:
|
||||||
|
lines.append(f"method={method}")
|
||||||
|
for i, t in enumerate(lines):
|
||||||
|
cv2.putText(
|
||||||
|
img_cv,
|
||||||
|
t,
|
||||||
|
(10, 22 + i * 18),
|
||||||
|
cv2.FONT_HERSHEY_SIMPLEX,
|
||||||
|
0.5,
|
||||||
|
(0, 255, 0),
|
||||||
|
1,
|
||||||
|
)
|
||||||
|
out = image.cv2image(img_cv, False, False)
|
||||||
|
|
||||||
|
lc = image.Color(config.LASER_COLOR[0], config.LASER_COLOR[1], config.LASER_COLOR[2])
|
||||||
|
out.draw_line(
|
||||||
|
int(x - config.LASER_LENGTH),
|
||||||
|
int(y),
|
||||||
|
int(x + config.LASER_LENGTH),
|
||||||
|
int(y),
|
||||||
|
lc,
|
||||||
|
config.LASER_THICKNESS,
|
||||||
|
)
|
||||||
|
out.draw_line(
|
||||||
|
int(x),
|
||||||
|
int(y - config.LASER_LENGTH),
|
||||||
|
int(x),
|
||||||
|
int(y + config.LASER_LENGTH),
|
||||||
|
lc,
|
||||||
|
config.LASER_THICKNESS,
|
||||||
|
)
|
||||||
|
out.draw_circle(int(x), int(y), 1, lc, config.LASER_THICKNESS)
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
class _AlgoWorker:
|
||||||
|
def __init__(self):
|
||||||
|
self._lock = threading.Lock()
|
||||||
|
self._busy = False
|
||||||
|
self._latest_preview = None
|
||||||
|
self._latest_meta = ""
|
||||||
|
self._last_ms = 0.0
|
||||||
|
|
||||||
|
@property
|
||||||
|
def busy(self):
|
||||||
|
with self._lock:
|
||||||
|
return self._busy
|
||||||
|
|
||||||
|
@property
|
||||||
|
def last_ms(self):
|
||||||
|
with self._lock:
|
||||||
|
return self._last_ms
|
||||||
|
|
||||||
|
def get_preview(self):
|
||||||
|
with self._lock:
|
||||||
|
return self._latest_preview, self._latest_meta
|
||||||
|
|
||||||
|
def run_async(self, frame):
|
||||||
|
with self._lock:
|
||||||
|
if self._busy:
|
||||||
|
return False
|
||||||
|
self._busy = True
|
||||||
|
|
||||||
|
def _job():
|
||||||
|
t0 = time.perf_counter()
|
||||||
|
meta = ""
|
||||||
|
preview = None
|
||||||
|
try:
|
||||||
|
analysis = analyze_shot(frame)
|
||||||
|
if not analysis.get("success"):
|
||||||
|
reason = analysis.get("reason", "unknown")
|
||||||
|
meta = f"fail:{reason}"
|
||||||
|
else:
|
||||||
|
preview = _annotate_like_saved_shot(analysis)
|
||||||
|
dx, dy = analysis.get("dx"), analysis.get("dy")
|
||||||
|
method = analysis.get("method") or "?"
|
||||||
|
if dx is not None and dy is not None:
|
||||||
|
meta = f"ok {method} ({dx:.2f},{dy:.2f})cm"
|
||||||
|
else:
|
||||||
|
meta = f"ok {method} no_offset"
|
||||||
|
except Exception as e:
|
||||||
|
meta = f"err:{e}"
|
||||||
|
elapsed = (time.perf_counter() - t0) * 1000.0
|
||||||
|
with self._lock:
|
||||||
|
self._latest_preview = preview
|
||||||
|
self._latest_meta = f"{meta} {elapsed:.0f}ms"
|
||||||
|
self._last_ms = elapsed
|
||||||
|
self._busy = False
|
||||||
|
|
||||||
|
threading.Thread(target=_job, daemon=True).start()
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
def _draw_status(frame, lines, color=None):
|
||||||
|
if color is None:
|
||||||
|
color = image.COLOR_YELLOW
|
||||||
|
y = 4
|
||||||
|
for line in lines:
|
||||||
|
frame.draw_string(4, y, line, color=color)
|
||||||
|
y += 16
|
||||||
|
|
||||||
|
|
||||||
|
def _save_preview_jpeg(maix_img, out_dir):
|
||||||
|
os.makedirs(out_dir, exist_ok=True)
|
||||||
|
fn = os.path.join(out_dir, f"preview_{int(time.time() * 1000)}.jpg")
|
||||||
|
maix_img.save(fn)
|
||||||
|
return fn
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
parser = argparse.ArgumentParser(description="实时预览射箭算法存图效果")
|
||||||
|
parser.add_argument(
|
||||||
|
"--interval",
|
||||||
|
type=float,
|
||||||
|
default=2.0,
|
||||||
|
help="两次完整 analyze_shot 的最小间隔(秒);--every-frame 时忽略",
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--every-frame",
|
||||||
|
action="store_true",
|
||||||
|
help="每帧都触发算法(很慢,仅调试用)",
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--width",
|
||||||
|
type=int,
|
||||||
|
default=getattr(config, "CAMERA_WIDTH", 640),
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--height",
|
||||||
|
type=int,
|
||||||
|
default=getattr(config, "CAMERA_HEIGHT", 480),
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--save-dir",
|
||||||
|
default=config.PHOTO_DIR,
|
||||||
|
help="按板子按键无;用 --save-every N 每 N 次成功分析存一张",
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--save-every",
|
||||||
|
type=int,
|
||||||
|
default=0,
|
||||||
|
help="每成功分析 N 次自动存一张到 --save-dir(0=不自动存)",
|
||||||
|
)
|
||||||
|
args = parser.parse_args()
|
||||||
|
|
||||||
|
_patch_preview_config()
|
||||||
|
print("[INFO] 预览模式:已关闭 TRIANGLE_BLACK_YOLO_SAVE_ROI_CROP / TRIANGLE_SAVE_DEBUG_IMAGE")
|
||||||
|
|
||||||
|
laser_manager.load_laser_point()
|
||||||
|
preload_triangle_calib()
|
||||||
|
if getattr(config, "TRIANGLE_YOLO_PRELOAD_ON_BOOT", False) or getattr(
|
||||||
|
config, "TRIANGLE_BLACK_YOLO_PRELOAD_ON_BOOT", False
|
||||||
|
):
|
||||||
|
print("[INFO] 预加载 YOLO …")
|
||||||
|
preload_yolo_detector()
|
||||||
|
|
||||||
|
camera_manager.init_camera(args.width, args.height)
|
||||||
|
camera_manager.init_display()
|
||||||
|
worker = _AlgoWorker()
|
||||||
|
|
||||||
|
interval_s = 0.0 if args.every_frame else max(0.3, float(args.interval))
|
||||||
|
last_trigger = 0.0
|
||||||
|
ok_count = 0
|
||||||
|
frame_idx = 0
|
||||||
|
|
||||||
|
print(
|
||||||
|
f"[INFO] 摄像头 {args.width}x{args.height} "
|
||||||
|
f"interval={'每帧' if args.every_frame else f'{interval_s}s'}"
|
||||||
|
)
|
||||||
|
print("[INFO] 退出:Ctrl+C")
|
||||||
|
|
||||||
|
try:
|
||||||
|
while True:
|
||||||
|
frame = camera_manager.read_frame()
|
||||||
|
frame_idx += 1
|
||||||
|
now = time.perf_counter()
|
||||||
|
|
||||||
|
due = args.every_frame or (now - last_trigger >= interval_s)
|
||||||
|
if due and not worker.busy:
|
||||||
|
last_trigger = now
|
||||||
|
worker.run_async(_copy_maix_frame(frame))
|
||||||
|
|
||||||
|
preview, meta = worker.get_preview()
|
||||||
|
if preview is not None:
|
||||||
|
show_img = preview
|
||||||
|
status = [f"#{frame_idx}", meta]
|
||||||
|
if args.save_every > 0 and meta.startswith("ok"):
|
||||||
|
ok_count += 1
|
||||||
|
if ok_count % args.save_every == 0:
|
||||||
|
try:
|
||||||
|
fn = _save_preview_jpeg(preview, args.save_dir)
|
||||||
|
status.append(f"saved:{fn}")
|
||||||
|
except Exception as e:
|
||||||
|
status.append(f"save_err:{e}")
|
||||||
|
else:
|
||||||
|
show_img = frame
|
||||||
|
if worker.busy:
|
||||||
|
status = [f"#{frame_idx}", "analyzing…"]
|
||||||
|
else:
|
||||||
|
status = [f"#{frame_idx}", "waiting…"]
|
||||||
|
|
||||||
|
_draw_status(show_img, status)
|
||||||
|
camera_manager.show(show_img)
|
||||||
|
maix_time.sleep_ms(1)
|
||||||
|
except KeyboardInterrupt:
|
||||||
|
print("[INFO] 已退出")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
@@ -1,144 +0,0 @@
|
|||||||
import importlib.util
|
|
||||||
from pathlib import Path
|
|
||||||
import sys
|
|
||||||
import types
|
|
||||||
import unittest
|
|
||||||
from unittest import mock
|
|
||||||
|
|
||||||
|
|
||||||
class _StopMonitor(Exception):
|
|
||||||
pass
|
|
||||||
|
|
||||||
|
|
||||||
class _FakeTime:
|
|
||||||
now_ms = 0
|
|
||||||
stop_at_ms = None
|
|
||||||
|
|
||||||
@classmethod
|
|
||||||
def reset(cls, stop_at_ms=None):
|
|
||||||
cls.now_ms = 0
|
|
||||||
cls.stop_at_ms = stop_at_ms
|
|
||||||
|
|
||||||
@classmethod
|
|
||||||
def ticks_ms(cls):
|
|
||||||
return cls.now_ms
|
|
||||||
|
|
||||||
@classmethod
|
|
||||||
def sleep_ms(cls, milliseconds):
|
|
||||||
cls.now_ms += milliseconds
|
|
||||||
if cls.stop_at_ms is not None and cls.now_ms >= cls.stop_at_ms:
|
|
||||||
raise _StopMonitor()
|
|
||||||
|
|
||||||
|
|
||||||
def _load_power_module():
|
|
||||||
module_path = Path(__file__).resolve().parents[1] / "power.py"
|
|
||||||
module_name = "power_charging_shutdown_test"
|
|
||||||
maix_module = types.ModuleType("maix")
|
|
||||||
maix_module.time = _FakeTime
|
|
||||||
|
|
||||||
previous_maix = sys.modules.get("maix")
|
|
||||||
sys.modules["maix"] = maix_module
|
|
||||||
try:
|
|
||||||
spec = importlib.util.spec_from_file_location(module_name, module_path)
|
|
||||||
module = importlib.util.module_from_spec(spec)
|
|
||||||
spec.loader.exec_module(module)
|
|
||||||
return module
|
|
||||||
finally:
|
|
||||||
if previous_maix is None:
|
|
||||||
sys.modules.pop("maix", None)
|
|
||||||
else:
|
|
||||||
sys.modules["maix"] = previous_maix
|
|
||||||
|
|
||||||
|
|
||||||
power = _load_power_module()
|
|
||||||
|
|
||||||
|
|
||||||
class ChargingShutdownTests(unittest.TestCase):
|
|
||||||
def setUp(self):
|
|
||||||
self.config_patch = mock.patch.multiple(
|
|
||||||
power.config,
|
|
||||||
CHARGING_SHUTDOWN_ENABLED=True,
|
|
||||||
CHARGING_DIAGNOSTIC_LOG_ENABLED=False,
|
|
||||||
CHARGING_CHECK_INTERVAL_MS=5000,
|
|
||||||
CHARGING_CURRENT_THRESHOLD_MA=100.0,
|
|
||||||
CHARGING_CONFIRM_COUNT=2,
|
|
||||||
CHARGING_NOTIFY_TIMEOUT_MS=30000,
|
|
||||||
CHARGING_EXIT_SCRIPT="/tmp/charging_exit.sh",
|
|
||||||
)
|
|
||||||
self.config_patch.start()
|
|
||||||
self.network_manager = mock.Mock()
|
|
||||||
self.network_manager.safe_enqueue_and_wait.return_value = True
|
|
||||||
network_module = types.ModuleType("network")
|
|
||||||
network_module.network_manager = self.network_manager
|
|
||||||
self.network_module_patch = mock.patch.dict(
|
|
||||||
sys.modules,
|
|
||||||
{"network": network_module},
|
|
||||||
)
|
|
||||||
self.network_module_patch.start()
|
|
||||||
_FakeTime.reset()
|
|
||||||
|
|
||||||
def tearDown(self):
|
|
||||||
self.network_module_patch.stop()
|
|
||||||
self.config_patch.stop()
|
|
||||||
|
|
||||||
def test_two_charging_samples_notify_server_and_exit(self):
|
|
||||||
popen_calls = []
|
|
||||||
with (
|
|
||||||
mock.patch.object(power, "get_current", return_value=-200.0),
|
|
||||||
mock.patch.object(power.os.path, "isfile", return_value=True),
|
|
||||||
mock.patch.object(
|
|
||||||
power.subprocess,
|
|
||||||
"Popen",
|
|
||||||
side_effect=lambda args: popen_calls.append(args),
|
|
||||||
),
|
|
||||||
):
|
|
||||||
power.charging_shutdown_monitor()
|
|
||||||
|
|
||||||
self.assertEqual(_FakeTime.now_ms, 5000)
|
|
||||||
self.assertEqual(len(popen_calls), 1)
|
|
||||||
self.network_manager.safe_enqueue_and_wait.assert_called_once_with(
|
|
||||||
{"poweroff": "充电中"}, 2, high=True, timeout_ms=30000
|
|
||||||
)
|
|
||||||
|
|
||||||
def test_discharging_does_not_notify_or_exit(self):
|
|
||||||
_FakeTime.reset(stop_at_ms=10000)
|
|
||||||
popen_calls = []
|
|
||||||
|
|
||||||
with (
|
|
||||||
mock.patch.object(power, "get_current", return_value=200.0),
|
|
||||||
mock.patch.object(power.os.path, "isfile", return_value=True),
|
|
||||||
mock.patch.object(
|
|
||||||
power.subprocess,
|
|
||||||
"Popen",
|
|
||||||
side_effect=lambda args: popen_calls.append(args),
|
|
||||||
),
|
|
||||||
self.assertRaises(_StopMonitor),
|
|
||||||
):
|
|
||||||
power.charging_shutdown_monitor()
|
|
||||||
|
|
||||||
self.assertEqual(popen_calls, [])
|
|
||||||
self.network_manager.safe_enqueue_and_wait.assert_not_called()
|
|
||||||
|
|
||||||
def test_failed_sample_resets_confirmation_count(self):
|
|
||||||
popen_calls = []
|
|
||||||
currents = iter((-200.0, 0.0, -200.0, -200.0))
|
|
||||||
with (
|
|
||||||
mock.patch.object(power, "get_current", side_effect=lambda: next(currents)),
|
|
||||||
mock.patch.object(power.os.path, "isfile", return_value=True),
|
|
||||||
mock.patch.object(
|
|
||||||
power.subprocess,
|
|
||||||
"Popen",
|
|
||||||
side_effect=lambda args: popen_calls.append(args),
|
|
||||||
),
|
|
||||||
):
|
|
||||||
power.charging_shutdown_monitor()
|
|
||||||
|
|
||||||
self.assertEqual(_FakeTime.now_ms, 15000)
|
|
||||||
self.assertEqual(len(popen_calls), 1)
|
|
||||||
self.network_manager.safe_enqueue_and_wait.assert_called_once_with(
|
|
||||||
{"poweroff": "充电中"}, 2, high=True, timeout_ms=30000
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
unittest.main()
|
|
||||||
@@ -1,330 +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
|
|
||||||
import math
|
|
||||||
|
|
||||||
# ==================== 全局配置 (与 test_main.py 保持一致) ====================
|
|
||||||
REAL_RADIUS_CM = 20 # 靶心实际半径(厘米)
|
|
||||||
|
|
||||||
def detect_circle_v3(frame, laser_point=None, img_cv=None):
|
|
||||||
"""检测图像中的靶心(优先清晰轮廓,其次黄色区域)- 返回椭圆参数版本
|
|
||||||
增加红色圆圈检测,验证黄色圆圈是否为真正的靶心
|
|
||||||
如果提供 laser_point,会选择最接近激光点的目标
|
|
||||||
优化:
|
|
||||||
1. 缩图到 MAX_DET_DIM 后再做 HSV/形态学,最长边 640->320 可获得 ~4x 加速
|
|
||||||
2. 红色掩码在黄色轮廓循环外只计算一次,避免 N 次重复计算
|
|
||||||
3. img_cv 可由外部传入(与其他线程共享转换结果),为 None 时自动转换
|
|
||||||
Args:
|
|
||||||
frame: 图像帧(img_cv 为 None 时使用)
|
|
||||||
laser_point: 激光点坐标 (x, y),用于多目标场景下的目标选择
|
|
||||||
img_cv: 已转换的 numpy BGR/RGB 图像;不为 None 时跳过 image2cv 转换
|
|
||||||
Returns:
|
|
||||||
(result_img, best_center, best_radius, method, best_radius1, ellipse_params)
|
|
||||||
"""
|
|
||||||
if img_cv is None:
|
|
||||||
img_cv = image.image2cv(frame, False, False)
|
|
||||||
from datetime import datetime
|
|
||||||
print(f"[detect_circle_v3] begin {datetime.now()}")
|
|
||||||
# -- 1. 缩图加速(与三角形路径保持一致)
|
|
||||||
h_orig, w_orig = img_cv.shape[:2]
|
|
||||||
MAX_DET_DIM = 480
|
|
||||||
long_side = max(h_orig, w_orig)
|
|
||||||
if long_side > MAX_DET_DIM:
|
|
||||||
det_scale = MAX_DET_DIM / long_side
|
|
||||||
img_det = cv2.resize(img_cv, (int(w_orig * det_scale), int(h_orig * det_scale)),
|
|
||||||
interpolation=cv2.INTER_LINEAR)
|
|
||||||
inv_scale = 1.0 / det_scale # 检测坐标 -> 原始坐标的倍率
|
|
||||||
else:
|
|
||||||
img_det = img_cv
|
|
||||||
inv_scale = 1.0
|
|
||||||
|
|
||||||
# 激光点映射到检测分辨率
|
|
||||||
lp_det = None
|
|
||||||
if laser_point is not None:
|
|
||||||
lp_det = (laser_point[0] / inv_scale, laser_point[1] / inv_scale)
|
|
||||||
best_center = best_radius = best_radius1 = method = None
|
|
||||||
ellipse_params = None
|
|
||||||
|
|
||||||
print(f"[detect_circle_v3] step 1 fin {datetime.now()}")
|
|
||||||
|
|
||||||
# -- 2. HSV + 黄色掩码
|
|
||||||
hsv = cv2.cvtColor(img_det, cv2.COLOR_RGB2HSV)
|
|
||||||
h, s, v = cv2.split(hsv)
|
|
||||||
s = np.clip(s * 1.1, 0, 255).astype(np.uint8)
|
|
||||||
hsv = cv2.merge((h, s, v))
|
|
||||||
lower_yellow = np.array([7, 80, 0])
|
|
||||||
upper_yellow = np.array([32, 255, 255])
|
|
||||||
mask_yellow = cv2.inRange(hsv, lower_yellow, upper_yellow)
|
|
||||||
kernel = cv2.getStructuringElement(cv2.MORPH_ELLIPSE, (5, 5))
|
|
||||||
mask_yellow = cv2.morphologyEx(mask_yellow, cv2.MORPH_CLOSE, kernel)
|
|
||||||
|
|
||||||
print(f"[detect_circle_v3] step 2 fin {datetime.now()}")
|
|
||||||
|
|
||||||
# -- 3. 红色掩码:在循环外只算一次
|
|
||||||
mask_red = cv2.bitwise_or(
|
|
||||||
cv2.inRange(hsv, np.array([0, 50, 40]), np.array([10, 255, 255])),
|
|
||||||
cv2.inRange(hsv, np.array([170, 50, 40]), np.array([180, 255, 255])),
|
|
||||||
)
|
|
||||||
kernel_red = cv2.getStructuringElement(cv2.MORPH_ELLIPSE, (5, 5))
|
|
||||||
mask_red = cv2.morphologyEx(mask_red, cv2.MORPH_CLOSE, kernel_red)
|
|
||||||
contours_red, _ = cv2.findContours(mask_red, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE)
|
|
||||||
# 预先把红色轮廓筛选成 (center, radius) 列表,后续直接查表
|
|
||||||
red_candidates = []
|
|
||||||
for cnt_r in contours_red:
|
|
||||||
ar = cv2.contourArea(cnt_r)
|
|
||||||
if ar <= 10:
|
|
||||||
continue
|
|
||||||
pr = cv2.arcLength(cnt_r, True)
|
|
||||||
if pr <= 0 or (4 * np.pi * ar) / (pr * pr) <= 0.3:
|
|
||||||
continue
|
|
||||||
if len(cnt_r) >= 5:
|
|
||||||
(xr, yr), (wr, hr), _ = cv2.fitEllipse(cnt_r)
|
|
||||||
red_candidates.append({"center": (int(xr), int(yr)), "radius": int(min(wr, hr) / 2)})
|
|
||||||
else:
|
|
||||||
(xr, yr), rr = cv2.minEnclosingCircle(cnt_r)
|
|
||||||
red_candidates.append({"center": (int(xr), int(yr)), "radius": int(rr)})
|
|
||||||
|
|
||||||
print(f"[detect_circle_v3] step 3 fin {datetime.now()}")
|
|
||||||
|
|
||||||
# -- 4. 黄色轮廓循环(复用上面的红色候选列表)
|
|
||||||
contours_yellow, _ = cv2.findContours(mask_yellow, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE)
|
|
||||||
valid_targets = []
|
|
||||||
for cnt_yellow in contours_yellow:
|
|
||||||
area = cv2.contourArea(cnt_yellow)
|
|
||||||
if area <= 15:
|
|
||||||
continue
|
|
||||||
perimeter = cv2.arcLength(cnt_yellow, True)
|
|
||||||
if perimeter <= 0:
|
|
||||||
continue
|
|
||||||
circularity = (4 * np.pi * area) / (perimeter * perimeter)
|
|
||||||
if circularity <= 0.5:
|
|
||||||
continue
|
|
||||||
print(f"[target] -> 面积:{area:.1f}, 圆度:{circularity:.2f}")
|
|
||||||
if len(cnt_yellow) >= 5:
|
|
||||||
(x, y), (width, height), angle = cv2.fitEllipse(cnt_yellow)
|
|
||||||
yellow_ellipse = ((x, y), (width, height), angle)
|
|
||||||
yellow_center = (int(x), int(y))
|
|
||||||
yellow_radius = int(min(width, height) / 2)
|
|
||||||
else:
|
|
||||||
(x, y), radius = cv2.minEnclosingCircle(cnt_yellow)
|
|
||||||
yellow_center = (int(x), int(y))
|
|
||||||
yellow_radius = int(radius)
|
|
||||||
yellow_ellipse = None
|
|
||||||
# 在预筛好的红色候选中匹配
|
|
||||||
matched = False
|
|
||||||
for rc in red_candidates:
|
|
||||||
ddx = yellow_center[0] - rc["center"][0]
|
|
||||||
ddy = yellow_center[1] - rc["center"][1]
|
|
||||||
dist_centers = math.hypot(ddx, ddy)
|
|
||||||
if dist_centers < yellow_radius * 1.5 and rc["radius"] > yellow_radius * 0.7:
|
|
||||||
print(f"[target] -> 找到匹配的红圈: 黄心({yellow_center}), "
|
|
||||||
f"红心({rc['center']}), 距离:{dist_centers:.1f}, "
|
|
||||||
f"黄半径:{yellow_radius}, 红半径:{rc['radius']}")
|
|
||||||
valid_targets.append({
|
|
||||||
"center": yellow_center,
|
|
||||||
"radius": yellow_radius,
|
|
||||||
"ellipse": yellow_ellipse,
|
|
||||||
"area": area,
|
|
||||||
})
|
|
||||||
matched = True
|
|
||||||
break
|
|
||||||
if not matched :
|
|
||||||
print("Debug -> 未找到匹配的红色圆圈,可能是误识别")
|
|
||||||
|
|
||||||
print(f"[detect_circle_v3] step 4 fin {datetime.now()}")
|
|
||||||
|
|
||||||
# -- 5. 选最佳目标,坐标还原到原始分辨率
|
|
||||||
if valid_targets:
|
|
||||||
if lp_det:
|
|
||||||
best_target = min(valid_targets,
|
|
||||||
key=lambda t: (t["center"][0] - lp_det[0]) ** 2
|
|
||||||
+ (t["center"][1] - lp_det[1]) ** 2)
|
|
||||||
method = "v3_ellipse_red_validated_laser_selected"
|
|
||||||
else:
|
|
||||||
best_target = max(valid_targets, key=lambda t: t["area"])
|
|
||||||
method = "v3_ellipse_red_validated"
|
|
||||||
bc = best_target["center"]
|
|
||||||
br = best_target["radius"]
|
|
||||||
be = best_target["ellipse"]
|
|
||||||
if inv_scale != 1.0:
|
|
||||||
best_center = (int(bc[0] * inv_scale), int(bc[1] * inv_scale))
|
|
||||||
best_radius = int(br * inv_scale)
|
|
||||||
if be is not None:
|
|
||||||
(ex, ey), (ew, eh), ea = be
|
|
||||||
be = ((ex * inv_scale, ey * inv_scale),
|
|
||||||
(ew * inv_scale, eh * inv_scale), ea)
|
|
||||||
else:
|
|
||||||
best_center = bc
|
|
||||||
best_radius = br
|
|
||||||
ellipse_params = be
|
|
||||||
best_radius1 = best_radius * 5
|
|
||||||
result_img = image.cv2image(img_cv, False, False)
|
|
||||||
print(f"[detect_circle_v3] step 5 fin {datetime.now()}")
|
|
||||||
return result_img, best_center, best_radius, method, best_radius1, ellipse_params
|
|
||||||
|
|
||||||
|
|
||||||
def run_offline_test(image_path):
|
|
||||||
"""读取图片,检测圆,绘制结果,保存图片"""
|
|
||||||
|
|
||||||
# 1. 检查文件是否存在
|
|
||||||
if not os.path.exists(image_path):
|
|
||||||
print(f"[ERROR] 找不到图片文件: {image_path}")
|
|
||||||
return
|
|
||||||
|
|
||||||
# 2. 使用 maix.image 读取图片 (适配 MaixPy v4)
|
|
||||||
try:
|
|
||||||
# 使用 image.load 读取文件,返回 Image 对象
|
|
||||||
img = image.load(image_path)
|
|
||||||
print(f"[INFO] 成功读取图片: {image_path} (尺寸: {img.width()}x{img.height()})")
|
|
||||||
except Exception as e:
|
|
||||||
print(f"[ERROR] 读取图片失败: {e}")
|
|
||||||
print("提示:请确认 MaixPy 版本是否为 v4,且图片路径正确。")
|
|
||||||
return
|
|
||||||
|
|
||||||
# 3. 调用 detect_circle_v3 函数
|
|
||||||
print("[INFO] 正在调用 detect_circle_v3 进行检测...")
|
|
||||||
start_time = time.ticks_ms()
|
|
||||||
|
|
||||||
result_img, center, radius, method, radius1, ellipse_params = detect_circle_v3(img)
|
|
||||||
|
|
||||||
cost_time = time.ticks_ms() - start_time
|
|
||||||
print(f"[INFO] 检测完成,耗时: {cost_time}ms")
|
|
||||||
print(f" 结果 -> 圆心: {center}, 半径: {radius}, 方法: {method}")
|
|
||||||
if ellipse_params:
|
|
||||||
(ell_center, (width, height), angle) = ellipse_params
|
|
||||||
print(
|
|
||||||
f" 椭圆 -> 中心: ({ell_center[0]:.1f}, {ell_center[1]:.1f}), 长轴: {max(width, height):.1f}, 短轴: {min(width, height):.1f}, 角度: {angle:.1f}°")
|
|
||||||
|
|
||||||
# 4. 绘制辅助线(可选,用于调试)
|
|
||||||
if center and radius:
|
|
||||||
# 为了绘制椭圆,需要转换回 cv2 图像
|
|
||||||
img_cv = image.image2cv(result_img, False, False)
|
|
||||||
|
|
||||||
cx, cy = center
|
|
||||||
|
|
||||||
# 如果有椭圆参数,绘制椭圆
|
|
||||||
if ellipse_params:
|
|
||||||
(ell_center, (width, height), angle) = ellipse_params
|
|
||||||
cx_ell, cy_ell = int(ell_center[0]), int(ell_center[1])
|
|
||||||
|
|
||||||
# 确定长轴和短轴
|
|
||||||
if width >= height:
|
|
||||||
# width 是长轴,height 是短轴
|
|
||||||
axes_major = width
|
|
||||||
axes_minor = height
|
|
||||||
major_angle = angle # 长轴角度就是 angle
|
|
||||||
minor_angle = angle + 90 # 短轴角度 = 长轴角度 + 90度
|
|
||||||
else:
|
|
||||||
# height 是长轴,width 是短轴
|
|
||||||
axes_major = height
|
|
||||||
axes_minor = width
|
|
||||||
major_angle = angle + 90 # 长轴角度 = width角度 + 90度
|
|
||||||
minor_angle = angle # 短轴角度就是 angle
|
|
||||||
|
|
||||||
# 使用 OpenCV 绘制椭圆(绿色,线宽2)
|
|
||||||
cv2.ellipse(img_cv,
|
|
||||||
(cx_ell, cy_ell), # 中心点
|
|
||||||
(int(width / 2), int(height / 2)), # 半宽、半高
|
|
||||||
angle, # 旋转角度(OpenCV需要原始angle)
|
|
||||||
0, 360, # 起始和结束角度
|
|
||||||
(0, 255, 0), # 绿色 (RGB格式)
|
|
||||||
2) # 线宽
|
|
||||||
|
|
||||||
# 绘制椭圆中心点(红色)
|
|
||||||
cv2.circle(img_cv, (cx_ell, cy_ell), 3, (255, 0, 0), -1)
|
|
||||||
|
|
||||||
import math
|
|
||||||
# 绘制短轴(蓝色线条)
|
|
||||||
minor_length = axes_minor / 2
|
|
||||||
minor_angle_rad = math.radians(minor_angle)
|
|
||||||
dx_minor = minor_length * math.cos(minor_angle_rad)
|
|
||||||
dy_minor = minor_length * math.sin(minor_angle_rad)
|
|
||||||
pt1_minor = (int(cx_ell - dx_minor), int(cy_ell - dy_minor))
|
|
||||||
pt2_minor = (int(cx_ell + dx_minor), int(cy_ell + dy_minor))
|
|
||||||
cv2.line(img_cv, pt1_minor, pt2_minor, (0, 0, 255), 2) # 蓝色 (RGB格式)
|
|
||||||
else:
|
|
||||||
# 如果没有椭圆参数,绘制圆形(红色)
|
|
||||||
cv2.circle(img_cv, (cx, cy), radius, (0, 0, 255), 2)
|
|
||||||
cv2.circle(img_cv, (cx, cy), 2, (0, 0, 255), -1)
|
|
||||||
|
|
||||||
# 转换回 maix image
|
|
||||||
result_img = image.cv2image(img_cv, False, False)
|
|
||||||
|
|
||||||
# 定义颜色对象用于文字
|
|
||||||
try:
|
|
||||||
color_black = image.Color.from_rgb(0, 0, 0)
|
|
||||||
except AttributeError:
|
|
||||||
color_black = image.Color(0, 0, 0)
|
|
||||||
|
|
||||||
# D. 添加文字信息
|
|
||||||
FOCAL_LENGTH_PIX = 1900
|
|
||||||
d = (REAL_RADIUS_CM * FOCAL_LENGTH_PIX) / radius1 / 100.0
|
|
||||||
info_str = f"R:{radius} M:{method} D:{d:.2f}"
|
|
||||||
print(info_str)
|
|
||||||
|
|
||||||
# 计算文字位置,防止超出图片边界
|
|
||||||
r_outer = int(radius * 11.0) if radius else 100
|
|
||||||
text_y = cy - r_outer - 20 if cy > r_outer + 20 else cy + r_outer + 20
|
|
||||||
|
|
||||||
# 调用 draw_string
|
|
||||||
result_img.draw_string(0, 0, info_str, color=color_black, scale=1.0)
|
|
||||||
|
|
||||||
# 5. 保存结果图片
|
|
||||||
base, ext = os.path.splitext(image_path)
|
|
||||||
output_path = f"{base}_result{ext}"
|
|
||||||
try:
|
|
||||||
result_img.save(output_path, quality=100)
|
|
||||||
print(f"[SUCCESS] 结果已保存至: {output_path}")
|
|
||||||
except Exception as e:
|
|
||||||
print(f"[ERROR] 保存图片失败: {e}")
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
# ================= 配置区域 =================
|
|
||||||
|
|
||||||
# 1. 设置要测试的图片路径
|
|
||||||
# 建议将图片放在与脚本同级目录,或者使用绝对路径
|
|
||||||
TARGET_IMAGE = "/root/phot/None_314_258_0_0041.bmp"
|
|
||||||
|
|
||||||
TARGET_DIR = "/root/phot" # 修改为你想要读取的目录路径
|
|
||||||
|
|
||||||
# 支持的图片格式
|
|
||||||
IMAGE_EXTENSIONS = ['.jpg', '.jpeg', '.png', '.bmp']
|
|
||||||
|
|
||||||
# ================= 执行区域 =================
|
|
||||||
if 'TARGET_DIR' in locals():
|
|
||||||
# 读取目录下所有图片文件,过滤掉 _result.jpg 后缀的文件
|
|
||||||
image_files = []
|
|
||||||
if os.path.exists(TARGET_DIR) and os.path.isdir(TARGET_DIR):
|
|
||||||
for filename in os.listdir(TARGET_DIR):
|
|
||||||
# 检查文件扩展名
|
|
||||||
if any(filename.lower().endswith(ext) for ext in IMAGE_EXTENSIONS):
|
|
||||||
# 过滤掉 _result.jpg 后缀的文件
|
|
||||||
if not filename.endswith('_result.jpg'):
|
|
||||||
filepath = os.path.join(TARGET_DIR, filename)
|
|
||||||
if os.path.isfile(filepath):
|
|
||||||
image_files.append(filepath)
|
|
||||||
|
|
||||||
# 按文件名排序(可选)
|
|
||||||
image_files.sort()
|
|
||||||
|
|
||||||
print(f"[INFO] 在目录 {TARGET_DIR} 中找到 {len(image_files)} 张图片")
|
|
||||||
|
|
||||||
# 处理每张图片
|
|
||||||
for img_path in image_files:
|
|
||||||
print(f"\n{'=' * 10} 开始处理: {img_path} {'=' * 10}")
|
|
||||||
run_offline_test(img_path)
|
|
||||||
else:
|
|
||||||
print(f"[ERROR] 目录不存在或不是有效目录: {TARGET_DIR}")
|
|
||||||
|
|
||||||
else:
|
|
||||||
run_offline_test(TARGET_IMAGE)
|
|
||||||
@@ -1,635 +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
|
|
||||||
|
|
||||||
if area > 50 and circularity > 0.7:
|
|
||||||
print(f"[target] -> 面积:{area}, 圆度:{circularity:.2f}")
|
|
||||||
# 尝试拟合椭圆
|
|
||||||
yellow_center = None
|
|
||||||
yellow_radius = None
|
|
||||||
yellow_ellipse = None
|
|
||||||
|
|
||||||
if len(cnt_yellow) >= 5:
|
|
||||||
(x, y), (width, height), angle = cv2.fitEllipse(cnt_yellow)
|
|
||||||
yellow_ellipse = ((x, y), (width, height), angle)
|
|
||||||
axes_minor = min(width, height)
|
|
||||||
radius = axes_minor / 2
|
|
||||||
yellow_center = (int(x), int(y))
|
|
||||||
yellow_radius = int(radius)
|
|
||||||
else:
|
|
||||||
(x, y), radius = cv2.minEnclosingCircle(cnt_yellow)
|
|
||||||
yellow_center = (int(x), int(y))
|
|
||||||
yellow_radius = int(radius)
|
|
||||||
yellow_ellipse = None
|
|
||||||
|
|
||||||
# 如果检测到黄色圆圈,再检测红色圆圈进行验证
|
|
||||||
if yellow_center and yellow_radius:
|
|
||||||
# HSV 红色掩码检测(红色在HSV中跨越0度,需要两个范围)
|
|
||||||
# 红色范围1: 0-12度(接近0度的红色)
|
|
||||||
# 放宽S/V阈值:S>=30, V>=20 以捕获淡红/暗红
|
|
||||||
lower_red1 = np.array([0, 30, 20])
|
|
||||||
upper_red1 = np.array([12, 255, 255])
|
|
||||||
mask_red1 = cv2.inRange(hsv, lower_red1, upper_red1)
|
|
||||||
|
|
||||||
# 红色范围2: 168-180度(接近180度的红色)
|
|
||||||
lower_red2 = np.array([168, 30, 20])
|
|
||||||
upper_red2 = np.array([180, 255, 255])
|
|
||||||
mask_red2 = cv2.inRange(hsv, lower_red2, upper_red2)
|
|
||||||
|
|
||||||
# 合并两个红色掩码
|
|
||||||
mask_red = cv2.bitwise_or(mask_red1, mask_red2)
|
|
||||||
|
|
||||||
# 形态学操作:先CLOSE填充空洞,再DILATE加厚环状区域
|
|
||||||
kernel_red = cv2.getStructuringElement(cv2.MORPH_ELLIPSE, (5, 5))
|
|
||||||
mask_red = cv2.morphologyEx(mask_red, cv2.MORPH_CLOSE, kernel_red)
|
|
||||||
mask_red = cv2.dilate(mask_red, kernel_red, iterations=1)
|
|
||||||
|
|
||||||
contours_red, _ = cv2.findContours(mask_red, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE)
|
|
||||||
|
|
||||||
red_pixel_count = np.sum(mask_red > 0)
|
|
||||||
print(f"Debug -> 红色掩码: {red_pixel_count} 像素, {len(contours_red)} 个轮廓")
|
|
||||||
|
|
||||||
found_valid_red = False
|
|
||||||
|
|
||||||
if contours_red:
|
|
||||||
for cnt_red in contours_red:
|
|
||||||
area_red = cv2.contourArea(cnt_red)
|
|
||||||
perimeter_red = cv2.arcLength(cnt_red, True)
|
|
||||||
|
|
||||||
if perimeter_red > 0:
|
|
||||||
circularity_red = (4 * np.pi * area_red) / (perimeter_red * perimeter_red)
|
|
||||||
else:
|
|
||||||
circularity_red = 0
|
|
||||||
|
|
||||||
# 环状轮廓圆度可能偏低,放宽到0.2
|
|
||||||
print(f"Debug -> 红轮廓: 面积={area_red:.1f}, 圆度={circularity_red:.2f}" +
|
|
||||||
f" (面积>15={area_red > 15}, 圆度>0.2={circularity_red > 0.2})")
|
|
||||||
if area_red > 15 and circularity_red > 0.2:
|
|
||||||
if len(cnt_red) >= 5:
|
|
||||||
(x_red, y_red), (w_red, h_red), angle_red = cv2.fitEllipse(cnt_red)
|
|
||||||
radius_red = min(w_red, h_red) / 2
|
|
||||||
red_center = (int(x_red), int(y_red))
|
|
||||||
red_radius = int(radius_red)
|
|
||||||
else:
|
|
||||||
(x_red, y_red), radius_red = cv2.minEnclosingCircle(cnt_red)
|
|
||||||
red_center = (int(x_red), int(y_red))
|
|
||||||
red_radius = int(radius_red)
|
|
||||||
|
|
||||||
if red_center:
|
|
||||||
dx = yellow_center[0] - red_center[0]
|
|
||||||
dy = yellow_center[1] - red_center[1]
|
|
||||||
distance = np.sqrt(dx * dx + dy * dy)
|
|
||||||
|
|
||||||
max_distance = yellow_radius * 2.0
|
|
||||||
min_r = min(red_radius, yellow_radius)
|
|
||||||
max_r = max(red_radius, yellow_radius)
|
|
||||||
size_ratio = min_r / max_r if max_r > 0 else 0
|
|
||||||
print(f"Debug -> 圆心距={distance:.1f}(阈值={max_distance:.1f}), "
|
|
||||||
f"大小比={size_ratio:.2f}(阈值=0.5), "
|
|
||||||
f"距离OK={distance < max_distance}, 大小OK={size_ratio > 0.5}")
|
|
||||||
|
|
||||||
# 允许红圈在黄圈外侧或内侧,只要大小相近(较小/较大 >= 0.5)
|
|
||||||
if distance < max_distance and size_ratio > 0.5:
|
|
||||||
found_valid_red = True
|
|
||||||
print(
|
|
||||||
f"[target] -> 找到匹配的红圈: 黄心({yellow_center}), 红心({red_center}), 距离:{distance:.1f}, 黄半径:{yellow_radius}, 红半径:{red_radius}")
|
|
||||||
|
|
||||||
valid_targets.append({
|
|
||||||
'center': yellow_center,
|
|
||||||
'radius': yellow_radius,
|
|
||||||
'ellipse': yellow_ellipse,
|
|
||||||
'area': area
|
|
||||||
})
|
|
||||||
break
|
|
||||||
|
|
||||||
if not found_valid_red:
|
|
||||||
# 如果黄圈非常可靠(大且圆),在没有红圈验证时仍接受
|
|
||||||
if area > 30 and circularity > 0.85:
|
|
||||||
print(f"[target] -> 黄圈高置信度(面积:{area:.0f}, 圆度:{circularity:.2f}),跳过红圈验证直接接受")
|
|
||||||
valid_targets.append({
|
|
||||||
'center': yellow_center,
|
|
||||||
'radius': yellow_radius,
|
|
||||||
'ellipse': yellow_ellipse,
|
|
||||||
'area': area
|
|
||||||
})
|
|
||||||
else:
|
|
||||||
print("Debug -> 未找到匹配的红色圆圈,可能是误识别")
|
|
||||||
|
|
||||||
# 从所有有效目标中选择最佳目标
|
|
||||||
if valid_targets:
|
|
||||||
if laser_point:
|
|
||||||
# 如果有激光点,选择最接近激光点的目标
|
|
||||||
best_target = None
|
|
||||||
min_distance = float('inf')
|
|
||||||
for target in valid_targets:
|
|
||||||
dx = target['center'][0] - laser_point[0]
|
|
||||||
dy = target['center'][1] - laser_point[1]
|
|
||||||
distance = np.sqrt(dx * dx + dy * dy)
|
|
||||||
if distance < min_distance:
|
|
||||||
min_distance = distance
|
|
||||||
best_target = target
|
|
||||||
if best_target:
|
|
||||||
best_center = best_target['center']
|
|
||||||
best_radius = best_target['radius']
|
|
||||||
ellipse_params = best_target['ellipse']
|
|
||||||
method = "v3_ellipse_red_validated_laser_selected"
|
|
||||||
best_radius1 = best_radius * 5
|
|
||||||
else:
|
|
||||||
# 如果没有激光点,选择面积最大的目标
|
|
||||||
best_target = max(valid_targets, key=lambda t: t['area'])
|
|
||||||
best_center = best_target['center']
|
|
||||||
best_radius = best_target['radius']
|
|
||||||
ellipse_params = best_target['ellipse']
|
|
||||||
method = "v3_ellipse_red_validated"
|
|
||||||
best_radius1 = best_radius * 5
|
|
||||||
|
|
||||||
result_img = image.cv2image(img_cv, False, False)
|
|
||||||
return result_img, best_center, best_radius, method, best_radius1, ellipse_params
|
|
||||||
|
|
||||||
|
|
||||||
def detect_circle(frame):
|
|
||||||
"""检测图像中的靶心(优先清晰轮廓,其次黄色区域)"""
|
|
||||||
img_cv = image.image2cv(frame, False, False)
|
|
||||||
# gray = cv2.cvtColor(img_cv, cv2.COLOR_RGB2GRAY)
|
|
||||||
# blurred = cv2.GaussianBlur(gray, (5, 5), 0)
|
|
||||||
# edged = cv2.Canny(blurred, 50, 150)
|
|
||||||
# kernel = cv2.getStructuringElement(cv2.MORPH_ELLIPSE, (5, 5))
|
|
||||||
# ceroded = cv2.erode(cv2.dilate(edged, kernel), kernel)
|
|
||||||
|
|
||||||
# contours, _ = cv2.findContours(ceroded, cv2.RETR_TREE, cv2.CHAIN_APPROX_SIMPLE)
|
|
||||||
# best_center = best_radius = best_radius1 = method = None
|
|
||||||
|
|
||||||
# hsv = cv2.cvtColor(img_cv, cv2.COLOR_RGB2HSV)
|
|
||||||
# h, s, v = cv2.split(hsv)
|
|
||||||
# s = np.clip(s * 2, 0, 255).astype(np.uint8)
|
|
||||||
# hsv = cv2.merge((h, s, v))
|
|
||||||
# lower_yellow = np.array([7, 80, 0])
|
|
||||||
# upper_yellow = np.array([32, 255, 182])
|
|
||||||
# mask = cv2.inRange(hsv, lower_yellow, upper_yellow)
|
|
||||||
# kernel = cv2.getStructuringElement(cv2.MORPH_ELLIPSE, (5, 5))
|
|
||||||
# mask = cv2.morphologyEx(mask, cv2.MORPH_OPEN, kernel)
|
|
||||||
# mask = cv2.morphologyEx(mask, cv2.MORPH_DILATE, kernel)
|
|
||||||
# contours, _ = cv2.findContours(mask, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE)
|
|
||||||
# if contours:
|
|
||||||
# largest = max(contours, key=cv2.contourArea)
|
|
||||||
# if cv2.contourArea(largest) > 50:
|
|
||||||
# (x, y), radius = cv2.minEnclosingCircle(largest)
|
|
||||||
# best_center = (int(x), int(y))
|
|
||||||
# best_radius = int(radius)
|
|
||||||
# best_radius1 = radius * 5
|
|
||||||
# method = "v2"
|
|
||||||
|
|
||||||
# auto
|
|
||||||
# R:31 M:v2 D:2.410110127692767
|
|
||||||
# hsv = cv2.cvtColor(img_cv, cv2.COLOR_RGB2HSV)
|
|
||||||
# h, s, v = cv2.split(hsv)
|
|
||||||
|
|
||||||
# # 1. 增强饱和度(模糊照片需要更强的增强)
|
|
||||||
# s = np.clip(s * 2.5, 0, 255).astype(np.uint8) # 从2.0改为2.5
|
|
||||||
|
|
||||||
# # 2. 增强亮度(模糊照片可能偏暗)
|
|
||||||
# v = np.clip(v * 1.2, 0, 255).astype(np.uint8) # 新增:提升亮度
|
|
||||||
|
|
||||||
# hsv = cv2.merge((h, s, v))
|
|
||||||
|
|
||||||
# # 3. 放宽HSV颜色范围(特别是模糊照片)
|
|
||||||
# # 降低饱和度下限,提高亮度上限
|
|
||||||
# lower_yellow = np.array([5, 50, 30]) # H:5-35, S:50-255, V:30-255
|
|
||||||
# upper_yellow = np.array([35, 255, 255])
|
|
||||||
|
|
||||||
# mask = cv2.inRange(hsv, lower_yellow, upper_yellow)
|
|
||||||
|
|
||||||
# # 4. 增强形态学操作(连接被分割的区域)
|
|
||||||
# kernel_small = cv2.getStructuringElement(cv2.MORPH_ELLIPSE, (5, 5))
|
|
||||||
# kernel_large = cv2.getStructuringElement(cv2.MORPH_ELLIPSE, (9, 9)) # 更大的核
|
|
||||||
|
|
||||||
# # 先开运算去除噪声
|
|
||||||
# mask = cv2.morphologyEx(mask, cv2.MORPH_OPEN, kernel_small)
|
|
||||||
# # 多次膨胀连接区域(模糊照片需要更多膨胀)
|
|
||||||
# mask = cv2.dilate(mask, kernel_large, iterations=2) # 增加迭代次数
|
|
||||||
# mask = cv2.morphologyEx(mask, cv2.MORPH_CLOSE, kernel_large) # 闭运算填充空洞
|
|
||||||
|
|
||||||
# contours, _ = cv2.findContours(mask, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE)
|
|
||||||
# if contours:
|
|
||||||
# largest = max(contours, key=cv2.contourArea)
|
|
||||||
# area = cv2.contourArea(largest)
|
|
||||||
# if area > 50:
|
|
||||||
# # 5. 使用面积计算等效半径(更准确)
|
|
||||||
# equivalent_radius = np.sqrt(area / np.pi)
|
|
||||||
|
|
||||||
# # 6. 同时使用minEnclosingCircle作为备选(取较大值)
|
|
||||||
# (x, y), enclosing_radius = cv2.minEnclosingCircle(largest)
|
|
||||||
|
|
||||||
# # 取两者中的较大值,确保不遗漏
|
|
||||||
# radius = max(equivalent_radius, enclosing_radius)
|
|
||||||
|
|
||||||
# best_center = (int(x), int(y))
|
|
||||||
# best_radius = int(radius)
|
|
||||||
# best_radius1 = radius * 5
|
|
||||||
# method = "v2"
|
|
||||||
|
|
||||||
# codegee
|
|
||||||
# R:24 M:v2 D:3.061493895819174
|
|
||||||
# R:22 M:v2 D:3.3644971681267077 np.clip(s * 1.1, 0, 255)
|
|
||||||
hsv = cv2.cvtColor(img_cv, cv2.COLOR_RGB2HSV)
|
|
||||||
h, s, v = cv2.split(hsv)
|
|
||||||
|
|
||||||
# 2. 调整饱和度策略:
|
|
||||||
# 不要暴力翻倍,可以尝试稍微增强,或者使用 CLAHE 增强亮度/对比度
|
|
||||||
# 这里我们稍微增加一点饱和度,并确保不溢出
|
|
||||||
s = np.clip(s * 1.1, 0, 255).astype(np.uint8)
|
|
||||||
# 对亮度通道 v 也可以做一点 CLAHE 处理来增强对比度(可选)
|
|
||||||
# clahe = cv2.createCLAHE(clipLimit=2.0, tileGridSize=(8,8))
|
|
||||||
# v = clahe.apply(v)
|
|
||||||
|
|
||||||
hsv = cv2.merge((h, s, v))
|
|
||||||
|
|
||||||
# 3. 放宽 HSV 阈值范围(针对模糊图像的关键调整)
|
|
||||||
# 降低 S 的下限 (80 -> 35),提高 V 的上限 (182 -> 255)
|
|
||||||
lower_yellow = np.array([7, 80, 0]) # 饱和度下限降低,捕捉淡黄色
|
|
||||||
upper_yellow = np.array([32, 255, 255]) # 亮度上限拉满
|
|
||||||
|
|
||||||
mask = cv2.inRange(hsv, lower_yellow, upper_yellow)
|
|
||||||
|
|
||||||
# 4. 调整形态学操作
|
|
||||||
# 去掉 MORPH_OPEN,因为它会减小面积。
|
|
||||||
# 使用 MORPH_CLOSE (先膨胀后腐蚀) 来填充内部小黑洞,连接近邻区域
|
|
||||||
kernel = cv2.getStructuringElement(cv2.MORPH_ELLIPSE, (5, 5))
|
|
||||||
mask = cv2.morphologyEx(mask, cv2.MORPH_CLOSE, kernel)
|
|
||||||
# 再进行一次膨胀,确保边缘被包含进来
|
|
||||||
# mask = cv2.dilate(mask, kernel, iterations=1)
|
|
||||||
|
|
||||||
contours, _ = cv2.findContours(mask, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE)
|
|
||||||
|
|
||||||
if contours:
|
|
||||||
largest = max(contours, key=cv2.contourArea)
|
|
||||||
|
|
||||||
# 这里可以适当降低面积阈值,或者保持不变
|
|
||||||
if cv2.contourArea(largest) > 50:
|
|
||||||
# (x, y), radius = cv2.minEnclosingCircle(largest)
|
|
||||||
# best_center = (int(x), int(y))
|
|
||||||
# best_radius = int(radius)
|
|
||||||
|
|
||||||
# --- 核心修改开始 ---
|
|
||||||
# 1. 尝试拟合椭圆 (需要轮廓点至少为5个)
|
|
||||||
if len(largest) >= 5:
|
|
||||||
# 返回值: ((中心x, 中心y), (长轴, 短轴), 旋转角度)
|
|
||||||
(x, y), (axes_major, axes_minor), angle = cv2.fitEllipse(largest)
|
|
||||||
|
|
||||||
# 2. 计算半径
|
|
||||||
# 选项A:取长短轴的平均值 (比较稳健)
|
|
||||||
# radius = (axes_major + axes_minor) / 4
|
|
||||||
|
|
||||||
# 选项B:直接取短轴的一半 (抗模糊最强,推荐)
|
|
||||||
radius = axes_minor / 2
|
|
||||||
|
|
||||||
best_center = (int(x), int(y))
|
|
||||||
best_radius = int(radius)
|
|
||||||
method = "v2_ellipse"
|
|
||||||
else:
|
|
||||||
# 如果点太少无法拟合椭圆,降级回 minEnclosingCircle
|
|
||||||
(x, y), radius = cv2.minEnclosingCircle(largest)
|
|
||||||
best_center = (int(x), int(y))
|
|
||||||
best_radius = int(radius)
|
|
||||||
method = "v2"
|
|
||||||
# --- 核心修改结束 ---
|
|
||||||
|
|
||||||
# 你的后续逻辑
|
|
||||||
best_radius1 = radius * 5
|
|
||||||
|
|
||||||
# operas 4.5
|
|
||||||
# R:25 M:v2 D:2.9554872521538527
|
|
||||||
# hsv = cv2.cvtColor(img_cv, cv2.COLOR_RGB2HSV)
|
|
||||||
# h, s, v = cv2.split(hsv)
|
|
||||||
|
|
||||||
# # 1. 适度增强饱和度(不要过度,否则噪声也会增强)
|
|
||||||
# s = np.clip(s * 1.5, 0, 255).astype(np.uint8)
|
|
||||||
# hsv = cv2.merge((h, s, v))
|
|
||||||
|
|
||||||
# # 2. 放宽 HSV 阈值范围(关键改动)
|
|
||||||
# # - 饱和度下限从 80 降到 40(捕捉淡黄色)
|
|
||||||
# # - 亮度上限从 182 提高到 255(允许更亮的黄色)
|
|
||||||
# lower_yellow = np.array([7, 40, 30])
|
|
||||||
# upper_yellow = np.array([35, 255, 255])
|
|
||||||
|
|
||||||
# mask = cv2.inRange(hsv, lower_yellow, upper_yellow)
|
|
||||||
|
|
||||||
# # 3. 调整形态学操作:用 CLOSE 替代 OPEN
|
|
||||||
# # CLOSE(先膨胀后腐蚀):填充内部空洞,连接相邻区域
|
|
||||||
# # OPEN(先腐蚀后膨胀):会缩小区域,不适合模糊图像
|
|
||||||
# kernel = cv2.getStructuringElement(cv2.MORPH_ELLIPSE, (7, 7)) # 稍大的核
|
|
||||||
# mask = cv2.morphologyEx(mask, cv2.MORPH_CLOSE, kernel)
|
|
||||||
# mask = cv2.dilate(mask, kernel, iterations=1) # 额外膨胀,确保边缘被包含
|
|
||||||
|
|
||||||
# contours, _ = cv2.findContours(mask, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE)
|
|
||||||
# if contours:
|
|
||||||
# largest = max(contours, key=cv2.contourArea)
|
|
||||||
# if cv2.contourArea(largest) > 50:
|
|
||||||
# (x, y), radius = cv2.minEnclosingCircle(largest)
|
|
||||||
# best_center = (int(x), int(y))
|
|
||||||
# best_radius = int(radius)
|
|
||||||
# best_radius1 = radius * 5
|
|
||||||
# method = "v2"
|
|
||||||
|
|
||||||
# # --- 新增:将 Mask 叠加到原图上用于调试 ---
|
|
||||||
# # 创建一个彩色掩码(红色通道为255,其他为0)
|
|
||||||
# mask_overlay = np.zeros_like(img_cv)
|
|
||||||
# mask_overlay[:, :, 2] = mask # 将掩码放在红色通道 (BGR中的R)
|
|
||||||
#
|
|
||||||
# cv2.addWeighted(img_cv, 0.6, mask_overlay, 0.4, 0, img_cv)
|
|
||||||
|
|
||||||
result_img = image.cv2image(img_cv, False, False)
|
|
||||||
return result_img, best_center, best_radius, method, best_radius1
|
|
||||||
|
|
||||||
|
|
||||||
def detect_circle_v2(frame):
|
|
||||||
"""检测图像中的靶心(优先清晰轮廓,其次黄色区域)- 返回椭圆参数版本"""
|
|
||||||
global REAL_RADIUS_CM
|
|
||||||
img_cv = image.image2cv(frame, False, False)
|
|
||||||
|
|
||||||
best_center = best_radius = best_radius1 = method = None
|
|
||||||
ellipse_params = None # 存储椭圆参数 ((x, y), (axes_major, axes_minor), angle)
|
|
||||||
|
|
||||||
# HSV 黄色掩码检测(模糊靶心)
|
|
||||||
hsv = cv2.cvtColor(img_cv, cv2.COLOR_RGB2HSV)
|
|
||||||
h, s, v = cv2.split(hsv)
|
|
||||||
|
|
||||||
# 调整饱和度策略:稍微增强,不要过度
|
|
||||||
s = np.clip(s * 1.1, 0, 255).astype(np.uint8)
|
|
||||||
|
|
||||||
hsv = cv2.merge((h, s, v))
|
|
||||||
|
|
||||||
# 放宽 HSV 阈值范围(针对模糊图像的关键调整)
|
|
||||||
lower_yellow = np.array([7, 80, 0]) # 饱和度下限降低,捕捉淡黄色
|
|
||||||
upper_yellow = np.array([32, 255, 255]) # 亮度上限拉满
|
|
||||||
|
|
||||||
mask = cv2.inRange(hsv, lower_yellow, upper_yellow)
|
|
||||||
|
|
||||||
# 调整形态学操作
|
|
||||||
# 使用 MORPH_CLOSE (先膨胀后腐蚀) 来填充内部小黑洞,连接近邻区域
|
|
||||||
kernel = cv2.getStructuringElement(cv2.MORPH_ELLIPSE, (5, 5))
|
|
||||||
mask = cv2.morphologyEx(mask, cv2.MORPH_CLOSE, kernel)
|
|
||||||
|
|
||||||
contours, _ = cv2.findContours(mask, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE)
|
|
||||||
|
|
||||||
if contours:
|
|
||||||
largest = max(contours, key=cv2.contourArea)
|
|
||||||
|
|
||||||
if cv2.contourArea(largest) > 50:
|
|
||||||
# 尝试拟合椭圆 (需要轮廓点至少为5个)
|
|
||||||
if len(largest) >= 5:
|
|
||||||
# 返回值: ((中心x, 中心y), (width, height), 旋转角度)
|
|
||||||
# 注意:width 和 height 是外接矩形的尺寸,不是长轴和短轴
|
|
||||||
(x, y), (width, height), angle = cv2.fitEllipse(largest)
|
|
||||||
|
|
||||||
# 保存椭圆参数(保持原始顺序,用于绘制)
|
|
||||||
ellipse_params = ((x, y), (width, height), angle)
|
|
||||||
|
|
||||||
# 计算半径:使用较小的尺寸作为短轴
|
|
||||||
axes_minor = min(width, height)
|
|
||||||
radius = axes_minor / 2
|
|
||||||
|
|
||||||
best_center = (int(x), int(y))
|
|
||||||
best_radius = int(radius)
|
|
||||||
method = "v2_ellipse"
|
|
||||||
else:
|
|
||||||
# 如果点太少无法拟合椭圆,降级回 minEnclosingCircle
|
|
||||||
(x, y), radius = cv2.minEnclosingCircle(largest)
|
|
||||||
best_center = (int(x), int(y))
|
|
||||||
best_radius = int(radius)
|
|
||||||
method = "v2"
|
|
||||||
ellipse_params = None # 圆形,没有椭圆参数
|
|
||||||
|
|
||||||
best_radius1 = radius * 5
|
|
||||||
|
|
||||||
result_img = image.cv2image(img_cv, False, False)
|
|
||||||
return result_img, best_center, best_radius, method, best_radius1, ellipse_params
|
|
||||||
|
|
||||||
|
|
||||||
# ==================== 测试逻辑 ====================
|
|
||||||
|
|
||||||
def run_offline_test(image_path):
|
|
||||||
"""读取图片,检测圆,绘制结果,保存图片"""
|
|
||||||
|
|
||||||
# 1. 检查文件是否存在
|
|
||||||
if not os.path.exists(image_path):
|
|
||||||
print(f"[ERROR] 找不到图片文件: {image_path}")
|
|
||||||
return
|
|
||||||
|
|
||||||
# 2. 使用 maix.image 读取图片 (适配 MaixPy v4)
|
|
||||||
try:
|
|
||||||
# 使用 image.load 读取文件,返回 Image 对象
|
|
||||||
img = image.load(image_path)
|
|
||||||
print(f"[INFO] 成功读取图片: {image_path} (尺寸: {img.width()}x{img.height()})")
|
|
||||||
except Exception as e:
|
|
||||||
print(f"[ERROR] 读取图片失败: {e}")
|
|
||||||
print("提示:请确认 MaixPy 版本是否为 v4,且图片路径正确。")
|
|
||||||
return
|
|
||||||
|
|
||||||
# 3. 调用 detect_circle_v2 函数
|
|
||||||
print("[INFO] 正在调用 detect_circle_v2 进行检测...")
|
|
||||||
start_time = time.ticks_ms()
|
|
||||||
|
|
||||||
result_img, center, radius, method, radius1, ellipse_params = detect_circle_v3(img)
|
|
||||||
|
|
||||||
cost_time = time.ticks_ms() - start_time
|
|
||||||
print(f"[INFO] 检测完成,耗时: {cost_time}ms")
|
|
||||||
print(f" 结果 -> 圆心: {center}, 半径: {radius}, 方法: {method}")
|
|
||||||
if ellipse_params:
|
|
||||||
(ell_center, (width, height), angle) = ellipse_params
|
|
||||||
print(
|
|
||||||
f" 椭圆 -> 中心: ({ell_center[0]:.1f}, {ell_center[1]:.1f}), 长轴: {max(width, height):.1f}, 短轴: {min(width, height):.1f}, 角度: {angle:.1f}°")
|
|
||||||
|
|
||||||
# 4. 绘制辅助线(可选,用于调试)
|
|
||||||
if center and radius:
|
|
||||||
# 为了绘制椭圆,需要转换回 cv2 图像
|
|
||||||
img_cv = image.image2cv(result_img, False, False)
|
|
||||||
|
|
||||||
cx, cy = center
|
|
||||||
|
|
||||||
# 如果有椭圆参数,绘制椭圆
|
|
||||||
if ellipse_params:
|
|
||||||
(ell_center, (width, height), angle) = ellipse_params
|
|
||||||
cx_ell, cy_ell = int(ell_center[0]), int(ell_center[1])
|
|
||||||
|
|
||||||
# 确定长轴和短轴
|
|
||||||
if width >= height:
|
|
||||||
# width 是长轴,height 是短轴
|
|
||||||
axes_major = width
|
|
||||||
axes_minor = height
|
|
||||||
major_angle = angle # 长轴角度就是 angle
|
|
||||||
minor_angle = angle + 90 # 短轴角度 = 长轴角度 + 90度
|
|
||||||
else:
|
|
||||||
# height 是长轴,width 是短轴
|
|
||||||
axes_major = height
|
|
||||||
axes_minor = width
|
|
||||||
major_angle = angle + 90 # 长轴角度 = width角度 + 90度
|
|
||||||
minor_angle = angle # 短轴角度就是 angle
|
|
||||||
|
|
||||||
# 使用 OpenCV 绘制椭圆(绿色,线宽2)
|
|
||||||
cv2.ellipse(img_cv,
|
|
||||||
(cx_ell, cy_ell), # 中心点
|
|
||||||
(int(width / 2), int(height / 2)), # 半宽、半高
|
|
||||||
angle, # 旋转角度(OpenCV需要原始angle)
|
|
||||||
0, 360, # 起始和结束角度
|
|
||||||
(0, 255, 0), # 绿色 (RGB格式)
|
|
||||||
2) # 线宽
|
|
||||||
|
|
||||||
# 绘制椭圆中心点(红色)
|
|
||||||
cv2.circle(img_cv, (cx_ell, cy_ell), 3, (255, 0, 0), -1)
|
|
||||||
|
|
||||||
import math
|
|
||||||
# 绘制短轴(蓝色线条)
|
|
||||||
minor_length = axes_minor / 2
|
|
||||||
minor_angle_rad = math.radians(minor_angle)
|
|
||||||
dx_minor = minor_length * math.cos(minor_angle_rad)
|
|
||||||
dy_minor = minor_length * math.sin(minor_angle_rad)
|
|
||||||
pt1_minor = (int(cx_ell - dx_minor), int(cy_ell - dy_minor))
|
|
||||||
pt2_minor = (int(cx_ell + dx_minor), int(cy_ell + dy_minor))
|
|
||||||
cv2.line(img_cv, pt1_minor, pt2_minor, (0, 0, 255), 2) # 蓝色 (RGB格式)
|
|
||||||
else:
|
|
||||||
# 如果没有椭圆参数,绘制圆形(红色)
|
|
||||||
cv2.circle(img_cv, (cx, cy), radius, (0, 0, 255), 2)
|
|
||||||
cv2.circle(img_cv, (cx, cy), 2, (0, 0, 255), -1)
|
|
||||||
|
|
||||||
# 转换回 maix image
|
|
||||||
result_img = image.cv2image(img_cv, False, False)
|
|
||||||
|
|
||||||
# 定义颜色对象用于文字
|
|
||||||
try:
|
|
||||||
color_black = image.Color.from_rgb(0, 0, 0)
|
|
||||||
except AttributeError:
|
|
||||||
color_black = image.Color(0, 0, 0)
|
|
||||||
|
|
||||||
# D. 添加文字信息
|
|
||||||
FOCAL_LENGTH_PIX = 1900
|
|
||||||
d = (REAL_RADIUS_CM * FOCAL_LENGTH_PIX) / radius1 / 100.0
|
|
||||||
info_str = f"R:{radius} M:{method} D:{d:.2f}"
|
|
||||||
print(info_str)
|
|
||||||
|
|
||||||
# 计算文字位置,防止超出图片边界
|
|
||||||
r_outer = int(radius * 11.0) if radius else 100
|
|
||||||
text_y = cy - r_outer - 20 if cy > r_outer + 20 else cy + r_outer + 20
|
|
||||||
|
|
||||||
# 调用 draw_string
|
|
||||||
result_img.draw_string(0, 0, info_str, color=color_black, scale=1.0)
|
|
||||||
|
|
||||||
# 5. 保存结果图片
|
|
||||||
output_path = image_path.replace(".bmp", "_result.bmp")
|
|
||||||
output_path = image_path.replace(".jpg", "_result.jpg")
|
|
||||||
try:
|
|
||||||
result_img.save(output_path, quality=100)
|
|
||||||
print(f"[SUCCESS] 结果已保存至: {output_path}")
|
|
||||||
except Exception as e:
|
|
||||||
print(f"[ERROR] 保存图片失败: {e}")
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
# ================= 配置区域 =================
|
|
||||||
|
|
||||||
# 1. 设置要测试的图片路径
|
|
||||||
# 建议将图片放在与脚本同级目录,或者使用绝对路径
|
|
||||||
TARGET_IMAGE = "/root/phot/None_314_258_0_0041.bmp"
|
|
||||||
|
|
||||||
TARGET_DIR = "/root/phot" # 修改为你想要读取的目录路径
|
|
||||||
|
|
||||||
# 支持的图片格式
|
|
||||||
IMAGE_EXTENSIONS = ['.jpg', '.jpeg', '.png', '.bmp']
|
|
||||||
|
|
||||||
# ================= 执行区域 =================
|
|
||||||
if 'TARGET_DIR' in locals():
|
|
||||||
# 读取目录下所有图片文件,过滤掉 _result.jpg 后缀的文件
|
|
||||||
image_files = []
|
|
||||||
if os.path.exists(TARGET_DIR) and os.path.isdir(TARGET_DIR):
|
|
||||||
for filename in os.listdir(TARGET_DIR):
|
|
||||||
# 检查文件扩展名
|
|
||||||
if any(filename.lower().endswith(ext) for ext in IMAGE_EXTENSIONS):
|
|
||||||
# 过滤掉 _result.jpg 后缀的文件
|
|
||||||
if filename.endswith('no_target.jpg'):
|
|
||||||
filepath = os.path.join(TARGET_DIR, filename)
|
|
||||||
if os.path.isfile(filepath):
|
|
||||||
image_files.append(filepath)
|
|
||||||
|
|
||||||
# 按文件名排序(可选)
|
|
||||||
image_files.sort()
|
|
||||||
|
|
||||||
print(f"[INFO] 在目录 {TARGET_DIR} 中找到 {len(image_files)} 张图片")
|
|
||||||
|
|
||||||
# 处理每张图片
|
|
||||||
for img_path in image_files:
|
|
||||||
print(f"\n{'=' * 10} 开始处理: {img_path} {'=' * 10}")
|
|
||||||
run_offline_test(img_path)
|
|
||||||
else:
|
|
||||||
print(f"[ERROR] 目录不存在或不是有效目录: {TARGET_DIR}")
|
|
||||||
|
|
||||||
else:
|
|
||||||
run_offline_test(TARGET_IMAGE)
|
|
||||||
@@ -0,0 +1,541 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
"""
|
||||||
|
激光中心点检测单元测试(单文件,无项目依赖)
|
||||||
|
直接使用 maix 标准库,实现红色激光点坐标检测
|
||||||
|
|
||||||
|
运行方式:
|
||||||
|
python3 test/test_laser_center_point.py
|
||||||
|
|
||||||
|
Ctrl+C 退出,按 s 保存截图
|
||||||
|
"""
|
||||||
|
|
||||||
|
from maix import camera, display, image, time, app, uart, pinmap
|
||||||
|
import os
|
||||||
|
import struct
|
||||||
|
import select
|
||||||
|
|
||||||
|
_USE_CV = False
|
||||||
|
try:
|
||||||
|
import cv2
|
||||||
|
import numpy as np
|
||||||
|
_USE_CV = True
|
||||||
|
except ImportError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
WIDTH = 640
|
||||||
|
HEIGHT = 480
|
||||||
|
THRESHOLD = 140
|
||||||
|
SEARCH_RADIUS = 50
|
||||||
|
|
||||||
|
|
||||||
|
def read_key_ev():
|
||||||
|
"""非阻塞读取 /dev/input/event0 按键(返回 key_code 或 -1)"""
|
||||||
|
try:
|
||||||
|
r, _, _ = select.select([_key_fd], [], [], 0)
|
||||||
|
if r:
|
||||||
|
event = _key_fd.read(16)
|
||||||
|
if len(event) == 16:
|
||||||
|
_, _, etype, code, value = struct.unpack("IIHHI", event)
|
||||||
|
if etype == 1 and value == 1:
|
||||||
|
return code
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
return -1
|
||||||
|
|
||||||
|
|
||||||
|
def find_ellipse(img_cv, cx, cy, roi_r, th):
|
||||||
|
x1 = max(0, cx - roi_r)
|
||||||
|
x2 = min(WIDTH, cx + roi_r)
|
||||||
|
y1 = max(0, cy - roi_r)
|
||||||
|
y2 = min(HEIGHT, cy + roi_r)
|
||||||
|
roi = img_cv[y1:y2, x1:x2]
|
||||||
|
if roi.size == 0:
|
||||||
|
return None
|
||||||
|
r = roi[:, :, 0].astype(np.int32)
|
||||||
|
g = roi[:, :, 1].astype(np.int32)
|
||||||
|
b = roi[:, :, 2].astype(np.int32)
|
||||||
|
mask = (r > th) & (r > g * 1.5) & (r > b * 1.5)
|
||||||
|
oe = (r > 200) & (g > 200) & (b > 200) & (r >= g) & (r >= b) & ((r - g) > 10) & ((r - b) > 10)
|
||||||
|
combined = (mask | oe).astype(np.uint8) * 255
|
||||||
|
contours, _ = cv2.findContours(combined, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE)
|
||||||
|
if not contours:
|
||||||
|
return None
|
||||||
|
largest = max(contours, key=cv2.contourArea)
|
||||||
|
if cv2.contourArea(largest) < 5:
|
||||||
|
return None
|
||||||
|
cnt = largest.copy()
|
||||||
|
for pt in cnt:
|
||||||
|
pt[0][0] += x1
|
||||||
|
pt[0][1] += y1
|
||||||
|
if len(cnt) >= 5:
|
||||||
|
(ex, ey), (ew, eh), ang = cv2.fitEllipse(cnt)
|
||||||
|
mask_ellipse = np.zeros((HEIGHT, WIDTH), dtype=np.uint8)
|
||||||
|
cv2.ellipse(mask_ellipse, (int(ex), int(ey)), (int(ew / 2), int(eh / 2)), ang, 0, 360, 255, -1)
|
||||||
|
brightness = img_cv[:, :, 0].astype(np.int32) + img_cv[:, :, 1].astype(np.int32) + img_cv[:, :, 2].astype(np.int32)
|
||||||
|
masked = np.where(mask_ellipse > 0, brightness, 0)
|
||||||
|
vals = masked[masked > 0]
|
||||||
|
if len(vals) > 0:
|
||||||
|
bth = np.percentile(vals, 90)
|
||||||
|
bmask = (masked >= bth).astype(np.uint8) * 255
|
||||||
|
bcontours, _ = cv2.findContours(bmask, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE)
|
||||||
|
if bcontours:
|
||||||
|
blargest = max(bcontours, key=cv2.contourArea)
|
||||||
|
if cv2.contourArea(blargest) >= 3 and len(blargest) >= 5:
|
||||||
|
(ix, iy), _, _ = cv2.fitEllipse(blargest)
|
||||||
|
return (float(ix), float(iy))
|
||||||
|
M = cv2.moments(blargest)
|
||||||
|
if M["m00"] > 0:
|
||||||
|
return (float(M["m10"] / M["m00"]), float(M["m01"] / M["m00"]))
|
||||||
|
return (float(ex), float(ey))
|
||||||
|
M = cv2.moments(cnt)
|
||||||
|
if M["m00"] > 0:
|
||||||
|
return (float(M["m10"] / M["m00"]), float(M["m01"] / M["m00"]))
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def find_brightest(img_cv, cx, cy, roi_r, th):
|
||||||
|
x1 = max(0, cx - roi_r)
|
||||||
|
x2 = min(WIDTH, cx + roi_r)
|
||||||
|
y1 = max(0, cy - roi_r)
|
||||||
|
y2 = min(HEIGHT, cy + roi_r)
|
||||||
|
best_score = 0
|
||||||
|
best_pos = None
|
||||||
|
for y in range(y1, y2):
|
||||||
|
for x in range(x1, x2):
|
||||||
|
r, g, b = int(img_cv[y, x, 0]), int(img_cv[y, x, 1]), int(img_cv[y, x, 2])
|
||||||
|
is_red = (r > th and r > g * 1.5 and r > b * 1.5)
|
||||||
|
is_oe = (r > 200 and g > 200 and b > 200 and r >= g and r >= b and (r - g) > 10 and (r - b) > 10)
|
||||||
|
if is_red or is_oe:
|
||||||
|
score = r + g + b
|
||||||
|
dx, dy = x - cx, y - cy
|
||||||
|
dist = (dx * dx + dy * dy) ** 0.5
|
||||||
|
score *= max(0.5, 1.0 - (dist / roi_r) * 0.5)
|
||||||
|
if score > best_score:
|
||||||
|
best_score = score
|
||||||
|
best_pos = (float(x), float(y))
|
||||||
|
return best_pos
|
||||||
|
|
||||||
|
|
||||||
|
# 打开键盘输入设备
|
||||||
|
_key_fd = None
|
||||||
|
try:
|
||||||
|
_key_fd = open("/dev/input/event0", "rb")
|
||||||
|
except Exception:
|
||||||
|
try:
|
||||||
|
_key_fd = open("/dev/input/event1", "rb")
|
||||||
|
except Exception:
|
||||||
|
_key_fd = None
|
||||||
|
|
||||||
|
print("=" * 50)
|
||||||
|
print("激光中心点检测单元测试")
|
||||||
|
print("=" * 50)
|
||||||
|
print()
|
||||||
|
|
||||||
|
cam = camera.Camera(WIDTH, HEIGHT)
|
||||||
|
disp = display.Display()
|
||||||
|
print("[OK] 摄像头和显示初始化完成")
|
||||||
|
|
||||||
|
# 初始化激光串口
|
||||||
|
_laser_on = False
|
||||||
|
_laser_uart = None
|
||||||
|
try:
|
||||||
|
pinmap.set_pin_function("A18", "UART1_RX")
|
||||||
|
pinmap.set_pin_function("A19", "UART1_TX")
|
||||||
|
_laser_uart = uart.UART("/dev/ttyS1", 9600)
|
||||||
|
_laser_uart.read(-1)
|
||||||
|
print("[OK] 激光串口初始化完成")
|
||||||
|
except Exception as e:
|
||||||
|
print(f"[WARN] 激光串口初始化失败: {e}")
|
||||||
|
|
||||||
|
LASER_ON = bytes([0xAA, 0x00, 0x01, 0xBE, 0x00, 0x01, 0x00, 0x01, 0xC1])
|
||||||
|
LASER_OFF = bytes([0xAA, 0x00, 0x01, 0xBE, 0x00, 0x01, 0x00, 0x00, 0xC0])
|
||||||
|
|
||||||
|
# 默认开启激光
|
||||||
|
if _laser_uart:
|
||||||
|
try:
|
||||||
|
_laser_uart.write(LASER_ON)
|
||||||
|
time.sleep_ms(50)
|
||||||
|
_laser_uart.read(-1)
|
||||||
|
_laser_on = True
|
||||||
|
print("[OK] 激光已开启")
|
||||||
|
except Exception as e:
|
||||||
|
print(f"[WARN] 开启激光失败: {e}")
|
||||||
|
print()
|
||||||
|
|
||||||
|
pos_ellipse = None
|
||||||
|
pos_bright = None
|
||||||
|
frame_count = 0
|
||||||
|
use_ellipse = True
|
||||||
|
|
||||||
|
while not app.need_exit():
|
||||||
|
frame = cam.read()
|
||||||
|
if frame is None:
|
||||||
|
time.sleep_ms(10)
|
||||||
|
continue
|
||||||
|
|
||||||
|
frame_count += 1
|
||||||
|
|
||||||
|
if _USE_CV:
|
||||||
|
img_cv = image.image2cv(frame, False, False)
|
||||||
|
cx, cy = WIDTH // 2, HEIGHT // 2
|
||||||
|
|
||||||
|
t0 = time.ticks_ms()
|
||||||
|
pos_ellipse = find_ellipse(img_cv, cx, cy, SEARCH_RADIUS, THRESHOLD)
|
||||||
|
t1 = time.ticks_ms()
|
||||||
|
pos_bright = find_brightest(img_cv, cx, cy, SEARCH_RADIUS, THRESHOLD)
|
||||||
|
t2 = time.ticks_ms()
|
||||||
|
|
||||||
|
dt_e = abs(time.ticks_diff(t0, t1))
|
||||||
|
dt_b = abs(time.ticks_diff(t1, t2))
|
||||||
|
|
||||||
|
if frame_count % 5 == 0:
|
||||||
|
e_str = f"({pos_ellipse[0]:.1f},{pos_ellipse[1]:.1f})" if pos_ellipse else "None"
|
||||||
|
b_str = f"({pos_bright[0]:.1f},{pos_bright[1]:.1f})" if pos_bright else "None"
|
||||||
|
print(f"[LASER] ellipse={e_str} ({dt_e}ms) brightest={b_str} ({dt_b}ms) "
|
||||||
|
f"th={THRESHOLD} radius={SEARCH_RADIUS}")
|
||||||
|
|
||||||
|
# 叠加显示
|
||||||
|
pos = pos_ellipse if use_ellipse else pos_bright
|
||||||
|
h, w = img_cv.shape[:2]
|
||||||
|
cv2.circle(img_cv, (cx, cy), SEARCH_RADIUS, (0, 255, 0), 1)
|
||||||
|
cv2.circle(img_cv, (cx, cy), 2, (0, 255, 0), -1)
|
||||||
|
if pos:
|
||||||
|
x, y = int(pos[0]), int(pos[1])
|
||||||
|
cv2.circle(img_cv, (x, y), 6, (0, 0, 255), 2)
|
||||||
|
cv2.line(img_cv, (x - 14, y), (x + 14, y), (0, 0, 255), 1)
|
||||||
|
cv2.line(img_cv, (x, y - 14), (x, y + 14), (0, 0, 255), 1)
|
||||||
|
cv2.putText(img_cv, f"({x},{y})", (x + 10, y - 10),
|
||||||
|
cv2.FONT_HERSHEY_SIMPLEX, 0.5, (0, 0, 255), 1, cv2.LINE_AA)
|
||||||
|
info = [
|
||||||
|
f"pos={pos if pos else 'None'}",
|
||||||
|
f"method={'ellipse' if use_ellipse else 'brightest'} th={THRESHOLD}",
|
||||||
|
f"laser={'ON' if _laser_on else 'OFF'}",
|
||||||
|
]
|
||||||
|
for i, line in enumerate(info):
|
||||||
|
cv2.putText(img_cv, line, (8, 20 + i * 22),
|
||||||
|
cv2.FONT_HERSHEY_SIMPLEX, 0.5, (0, 255, 0), 1, cv2.LINE_AA)
|
||||||
|
|
||||||
|
display_frame = image.cv2image(img_cv, False, False)
|
||||||
|
else:
|
||||||
|
display_frame = frame
|
||||||
|
|
||||||
|
disp.show(display_frame)
|
||||||
|
|
||||||
|
# 按键处理(非阻塞)
|
||||||
|
key = read_key_ev()
|
||||||
|
if key > 0:
|
||||||
|
c = chr(key & 0xFF) if key < 256 else ""
|
||||||
|
if key == 113 or key == 81 or key == 0x1b: # q/Q/ESC
|
||||||
|
break
|
||||||
|
if c == "e" or key == 18: # e
|
||||||
|
use_ellipse = not use_ellipse
|
||||||
|
print(f"[KEY] Method: {'ellipse' if use_ellipse else 'brightest'}")
|
||||||
|
if c == "l" or key == 12: # l
|
||||||
|
_laser_on = not _laser_on
|
||||||
|
if _laser_uart:
|
||||||
|
try:
|
||||||
|
_laser_uart.write(LASER_ON if _laser_on else LASER_OFF)
|
||||||
|
time.sleep_ms(30)
|
||||||
|
_laser_uart.read(-1)
|
||||||
|
print(f"[KEY] Laser: {'ON' if _laser_on else 'OFF'}")
|
||||||
|
except Exception as e:
|
||||||
|
print(f"[KEY] Laser error: {e}")
|
||||||
|
else:
|
||||||
|
print("[KEY] Laser UART not available")
|
||||||
|
time.sleep_ms(30)
|
||||||
|
|
||||||
|
# 关闭激光
|
||||||
|
if _laser_on and _laser_uart:
|
||||||
|
try:
|
||||||
|
_laser_uart.write(LASER_OFF)
|
||||||
|
_laser_uart.read(-1)
|
||||||
|
print("[EXIT] 激光已关闭")
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
print("[EXIT] 测试结束")
|
||||||
|
if _key_fd:
|
||||||
|
_key_fd.close()
|
||||||
|
#!/usr/bin/env python3
|
||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
"""
|
||||||
|
激光中心点检测单元测试(单文件,无项目依赖)
|
||||||
|
直接使用 maix 标准库,实现红色激光点坐标检测
|
||||||
|
|
||||||
|
运行方式:
|
||||||
|
python3 test/test_laser_center_point.py
|
||||||
|
|
||||||
|
Ctrl+C 退出,按 s 保存截图
|
||||||
|
"""
|
||||||
|
|
||||||
|
from maix import camera, display, image, time, app, uart, pinmap
|
||||||
|
import os
|
||||||
|
import struct
|
||||||
|
import select
|
||||||
|
|
||||||
|
_USE_CV = False
|
||||||
|
try:
|
||||||
|
import cv2
|
||||||
|
import numpy as np
|
||||||
|
_USE_CV = True
|
||||||
|
except ImportError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
WIDTH = 640
|
||||||
|
HEIGHT = 480
|
||||||
|
THRESHOLD = 120
|
||||||
|
RED_RATIO = 1.3
|
||||||
|
SEARCH_RADIUS = 60
|
||||||
|
|
||||||
|
|
||||||
|
def read_key_ev():
|
||||||
|
"""非阻塞读取 /dev/input/event0 按键(返回 key_code 或 -1)"""
|
||||||
|
try:
|
||||||
|
r, _, _ = select.select([_key_fd], [], [], 0)
|
||||||
|
if r:
|
||||||
|
event = _key_fd.read(16)
|
||||||
|
if len(event) == 16:
|
||||||
|
_, _, etype, code, value = struct.unpack("IIHHI", event)
|
||||||
|
if etype == 1 and value == 1:
|
||||||
|
return code
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
return -1
|
||||||
|
|
||||||
|
|
||||||
|
def find_ellipse(img_cv, cx, cy, roi_r, th, ratio):
|
||||||
|
x1 = max(0, cx - roi_r)
|
||||||
|
x2 = min(WIDTH, cx + roi_r)
|
||||||
|
y1 = max(0, cy - roi_r)
|
||||||
|
y2 = min(HEIGHT, cy + roi_r)
|
||||||
|
roi = img_cv[y1:y2, x1:x2]
|
||||||
|
if roi.size == 0:
|
||||||
|
return None
|
||||||
|
r = roi[:, :, 0].astype(np.int32)
|
||||||
|
g = roi[:, :, 1].astype(np.int32)
|
||||||
|
b = roi[:, :, 2].astype(np.int32)
|
||||||
|
mask = (r > th) & (r > g * ratio) & (r > b * ratio)
|
||||||
|
oe = (r > 200) & (g > 200) & (b > 200) & (r >= g) & (r >= b) & ((r - g) > 10) & ((r - b) > 10)
|
||||||
|
combined = (mask | oe).astype(np.uint8) * 255
|
||||||
|
contours, _ = cv2.findContours(combined, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE)
|
||||||
|
if not contours:
|
||||||
|
return None
|
||||||
|
largest = max(contours, key=cv2.contourArea)
|
||||||
|
if cv2.contourArea(largest) < 5:
|
||||||
|
return None
|
||||||
|
cnt = largest.copy()
|
||||||
|
for pt in cnt:
|
||||||
|
pt[0][0] += x1
|
||||||
|
pt[0][1] += y1
|
||||||
|
if len(cnt) >= 5:
|
||||||
|
(ex, ey), (ew, eh), ang = cv2.fitEllipse(cnt)
|
||||||
|
mask_ellipse = np.zeros((HEIGHT, WIDTH), dtype=np.uint8)
|
||||||
|
cv2.ellipse(mask_ellipse, (int(ex), int(ey)), (int(ew / 2), int(eh / 2)), ang, 0, 360, 255, -1)
|
||||||
|
brightness = img_cv[:, :, 0].astype(np.int32) + img_cv[:, :, 1].astype(np.int32) + img_cv[:, :, 2].astype(np.int32)
|
||||||
|
masked = np.where(mask_ellipse > 0, brightness, 0)
|
||||||
|
vals = masked[masked > 0]
|
||||||
|
if len(vals) > 0:
|
||||||
|
bth = np.percentile(vals, 90)
|
||||||
|
bmask = (masked >= bth).astype(np.uint8) * 255
|
||||||
|
bcontours, _ = cv2.findContours(bmask, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE)
|
||||||
|
if bcontours:
|
||||||
|
blargest = max(bcontours, key=cv2.contourArea)
|
||||||
|
if cv2.contourArea(blargest) >= 3 and len(blargest) >= 5:
|
||||||
|
(ix, iy), _, _ = cv2.fitEllipse(blargest)
|
||||||
|
return (float(ix), float(iy))
|
||||||
|
M = cv2.moments(blargest)
|
||||||
|
if M["m00"] > 0:
|
||||||
|
return (float(M["m10"] / M["m00"]), float(M["m01"] / M["m00"]))
|
||||||
|
return (float(ex), float(ey))
|
||||||
|
M = cv2.moments(cnt)
|
||||||
|
if M["m00"] > 0:
|
||||||
|
return (float(M["m10"] / M["m00"]), float(M["m01"] / M["m00"]))
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def find_brightest_bytes(frame, cx, cy, roi_r, th, ratio):
|
||||||
|
"""使用 frame.to_bytes() 两阶段搜索,避免 cv2 转换"""
|
||||||
|
x1 = max(0, cx - roi_r)
|
||||||
|
x2 = min(WIDTH, cx + roi_r)
|
||||||
|
y1 = max(0, cy - roi_r)
|
||||||
|
y2 = min(HEIGHT, cy + roi_r)
|
||||||
|
data = frame.to_bytes()
|
||||||
|
best_score = 0
|
||||||
|
best_pos = None
|
||||||
|
# 第一阶段:隔点粗搜
|
||||||
|
for y in range(y1, y2, 2):
|
||||||
|
for x in range(x1, x2, 2):
|
||||||
|
idx = (y * WIDTH + x) * 3
|
||||||
|
r = data[idx]; g = data[idx+1]; b = data[idx+2]
|
||||||
|
if (r > th and r > g * ratio and r > b * ratio) or \
|
||||||
|
(r > 200 and g > 200 and b > 200 and r >= g and r >= b and (r - g) > 10 and (r - b) > 10):
|
||||||
|
score = r + g + b
|
||||||
|
dx = x - cx; dy = y - cy
|
||||||
|
score *= max(0.5, 1.0 - ((dx*dx + dy*dy) ** 0.5 / roi_r) * 0.5)
|
||||||
|
if score > best_score:
|
||||||
|
best_score = score
|
||||||
|
best_pos = (x, y)
|
||||||
|
if best_pos is None:
|
||||||
|
return None
|
||||||
|
# 第二阶段:候选点 7x7 精细搜索
|
||||||
|
fx, fy = best_pos
|
||||||
|
x1f = max(0, fx - 3); x2f = min(WIDTH, fx + 4)
|
||||||
|
y1f = max(0, fy - 3); y2f = min(HEIGHT, fy + 4)
|
||||||
|
best_bright = 0
|
||||||
|
final_pos = best_pos
|
||||||
|
for y in range(y1f, y2f):
|
||||||
|
for x in range(x1f, x2f):
|
||||||
|
idx = (y * WIDTH + x) * 3
|
||||||
|
r = data[idx]; g = data[idx+1]; b = data[idx+2]
|
||||||
|
if (r > th and r > g * ratio and r > b * ratio) or \
|
||||||
|
(r > 200 and g > 200 and b > 200 and r >= g and r >= b and (r - g) > 10 and (r - b) > 10):
|
||||||
|
rgb_sum = r + g + b
|
||||||
|
if rgb_sum > best_bright:
|
||||||
|
best_bright = rgb_sum
|
||||||
|
final_pos = (float(x), float(y))
|
||||||
|
return final_pos
|
||||||
|
|
||||||
|
|
||||||
|
# 打开键盘输入设备
|
||||||
|
_key_fd = None
|
||||||
|
try:
|
||||||
|
_key_fd = open("/dev/input/event0", "rb")
|
||||||
|
except Exception:
|
||||||
|
try:
|
||||||
|
_key_fd = open("/dev/input/event1", "rb")
|
||||||
|
except Exception:
|
||||||
|
_key_fd = None
|
||||||
|
|
||||||
|
print("=" * 50)
|
||||||
|
print("激光中心点检测单元测试")
|
||||||
|
print("=" * 50)
|
||||||
|
print()
|
||||||
|
|
||||||
|
cam = camera.Camera(WIDTH, HEIGHT)
|
||||||
|
disp = display.Display()
|
||||||
|
print("[OK] 摄像头和显示初始化完成")
|
||||||
|
|
||||||
|
# 初始化激光串口
|
||||||
|
_laser_on = False
|
||||||
|
_laser_uart = None
|
||||||
|
try:
|
||||||
|
pinmap.set_pin_function("A18", "UART1_RX")
|
||||||
|
pinmap.set_pin_function("A19", "UART1_TX")
|
||||||
|
_laser_uart = uart.UART("/dev/ttyS1", 9600)
|
||||||
|
_laser_uart.read(-1)
|
||||||
|
print("[OK] 激光串口初始化完成")
|
||||||
|
except Exception as e:
|
||||||
|
print(f"[WARN] 激光串口初始化失败: {e}")
|
||||||
|
|
||||||
|
LASER_ON = bytes([0xAA, 0x00, 0x01, 0xBE, 0x00, 0x01, 0x00, 0x01, 0xC1])
|
||||||
|
LASER_OFF = bytes([0xAA, 0x00, 0x01, 0xBE, 0x00, 0x01, 0x00, 0x00, 0xC0])
|
||||||
|
|
||||||
|
# 默认开启激光
|
||||||
|
if _laser_uart:
|
||||||
|
try:
|
||||||
|
_laser_uart.write(LASER_ON)
|
||||||
|
time.sleep_ms(50)
|
||||||
|
_laser_uart.read(-1)
|
||||||
|
_laser_on = True
|
||||||
|
print("[OK] 激光已开启")
|
||||||
|
except Exception as e:
|
||||||
|
print(f"[WARN] 开启激光失败: {e}")
|
||||||
|
print()
|
||||||
|
|
||||||
|
pos_ellipse = None
|
||||||
|
pos_bright = None
|
||||||
|
frame_count = 0
|
||||||
|
use_ellipse = True
|
||||||
|
|
||||||
|
while not app.need_exit():
|
||||||
|
frame = cam.read()
|
||||||
|
if frame is None:
|
||||||
|
time.sleep_ms(10)
|
||||||
|
continue
|
||||||
|
|
||||||
|
frame_count += 1
|
||||||
|
|
||||||
|
cx, cy = WIDTH // 2, HEIGHT // 2
|
||||||
|
|
||||||
|
t0 = time.ticks_ms()
|
||||||
|
pos_bright = find_brightest_bytes(frame, cx, cy, SEARCH_RADIUS, THRESHOLD, RED_RATIO)
|
||||||
|
t1 = time.ticks_ms()
|
||||||
|
|
||||||
|
pos_ellipse = None
|
||||||
|
if _USE_CV:
|
||||||
|
img_cv = image.image2cv(frame, False, False)
|
||||||
|
t2 = time.ticks_ms()
|
||||||
|
pos_ellipse = find_ellipse(img_cv, cx, cy, SEARCH_RADIUS, THRESHOLD, RED_RATIO)
|
||||||
|
t3 = time.ticks_ms()
|
||||||
|
else:
|
||||||
|
img_cv = None
|
||||||
|
t3 = t2 = t1
|
||||||
|
|
||||||
|
dt_b = abs(time.ticks_diff(t0, t1))
|
||||||
|
dt_e = abs(time.ticks_diff(t2, t3))
|
||||||
|
|
||||||
|
if frame_count % 5 == 0:
|
||||||
|
e_str = f"({pos_ellipse[0]:.1f},{pos_ellipse[1]:.1f})" if pos_ellipse else "None"
|
||||||
|
b_str = f"({pos_bright[0]:.1f},{pos_bright[1]:.1f})" if pos_bright else "None"
|
||||||
|
print(f"[LASER] ellipse={e_str} ({dt_e}ms) brightest={b_str} ({dt_b}ms) "
|
||||||
|
f"th={THRESHOLD} ratio={RED_RATIO} radius={SEARCH_RADIUS}")
|
||||||
|
|
||||||
|
pos = pos_ellipse if use_ellipse else pos_bright
|
||||||
|
if img_cv is not None:
|
||||||
|
cv2.circle(img_cv, (cx, cy), SEARCH_RADIUS, (0, 255, 0), 1)
|
||||||
|
cv2.circle(img_cv, (cx, cy), 2, (0, 255, 0), -1)
|
||||||
|
if pos:
|
||||||
|
x, y = int(pos[0]), int(pos[1])
|
||||||
|
cv2.circle(img_cv, (x, y), 6, (0, 0, 255), 2)
|
||||||
|
cv2.line(img_cv, (x - 14, y), (x + 14, y), (0, 0, 255), 1)
|
||||||
|
cv2.line(img_cv, (x, y - 14), (x, y + 14), (0, 0, 255), 1)
|
||||||
|
cv2.putText(img_cv, f"({x},{y})", (x + 10, y - 10),
|
||||||
|
cv2.FONT_HERSHEY_SIMPLEX, 0.5, (0, 0, 255), 1, cv2.LINE_AA)
|
||||||
|
info = [
|
||||||
|
f"pos={pos if pos else 'None'}",
|
||||||
|
f"method={'ellipse' if use_ellipse else 'brightest'} th={THRESHOLD} ratio={RED_RATIO}",
|
||||||
|
f"laser={'ON' if _laser_on else 'OFF'}",
|
||||||
|
]
|
||||||
|
for i, line in enumerate(info):
|
||||||
|
cv2.putText(img_cv, line, (8, 20 + i * 22),
|
||||||
|
cv2.FONT_HERSHEY_SIMPLEX, 0.5, (0, 255, 0), 1, cv2.LINE_AA)
|
||||||
|
display_frame = image.cv2image(img_cv, False, False)
|
||||||
|
else:
|
||||||
|
display_frame = frame
|
||||||
|
|
||||||
|
disp.show(display_frame)
|
||||||
|
|
||||||
|
# 按键处理(非阻塞)
|
||||||
|
key = read_key_ev()
|
||||||
|
if key > 0:
|
||||||
|
c = chr(key & 0xFF) if key < 256 else ""
|
||||||
|
if key == 113 or key == 81 or key == 0x1b: # q/Q/ESC
|
||||||
|
break
|
||||||
|
if c == "e" or key == 18: # e
|
||||||
|
use_ellipse = not use_ellipse
|
||||||
|
print(f"[KEY] Method: {'ellipse' if use_ellipse else 'brightest'}")
|
||||||
|
if c == "l" or key == 12: # l
|
||||||
|
_laser_on = not _laser_on
|
||||||
|
if _laser_uart:
|
||||||
|
try:
|
||||||
|
_laser_uart.write(LASER_ON if _laser_on else LASER_OFF)
|
||||||
|
time.sleep_ms(30)
|
||||||
|
_laser_uart.read(-1)
|
||||||
|
print(f"[KEY] Laser: {'ON' if _laser_on else 'OFF'}")
|
||||||
|
except Exception as e:
|
||||||
|
print(f"[KEY] Laser error: {e}")
|
||||||
|
else:
|
||||||
|
print("[KEY] Laser UART not available")
|
||||||
|
time.sleep_ms(30)
|
||||||
|
|
||||||
|
# 关闭激光
|
||||||
|
if _laser_on and _laser_uart:
|
||||||
|
try:
|
||||||
|
_laser_uart.write(LASER_OFF)
|
||||||
|
_laser_uart.read(-1)
|
||||||
|
print("[EXIT] 激光已关闭")
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
print("[EXIT] 测试结束")
|
||||||
|
if _key_fd:
|
||||||
|
_key_fd.close()
|
||||||
@@ -1,59 +0,0 @@
|
|||||||
#!/usr/bin/env python3
|
|
||||||
# -*- coding: utf-8 -*-
|
|
||||||
"""Read the digital voltage level on the MaixCAM P21 pin.
|
|
||||||
|
|
||||||
P21 is a digital GPIO pin, not the MaixCAM analog ADC input. Therefore this
|
|
||||||
script can only distinguish LOW and HIGH. For a continuous voltage value,
|
|
||||||
connect the signal to the board's B3/ADC pin and use ADC channel 0 instead.
|
|
||||||
|
|
||||||
Do not apply more than 3.3 V to P21. Always connect the signal ground to the
|
|
||||||
MaixCAM ground.
|
|
||||||
"""
|
|
||||||
|
|
||||||
from maix import app, gpio, pinmap, time
|
|
||||||
|
|
||||||
|
|
||||||
PIN = "P21"
|
|
||||||
IO_HIGH_VOLTAGE = 3.3
|
|
||||||
SAMPLE_INTERVAL_MS = 200
|
|
||||||
|
|
||||||
|
|
||||||
def find_gpio_function(pin):
|
|
||||||
"""Return the GPIO function supported by the requested physical pin."""
|
|
||||||
functions = pinmap.get_pin_functions(pin)
|
|
||||||
gpio_functions = [name for name in functions if name.startswith("GPIO")]
|
|
||||||
|
|
||||||
print(f"{pin} supported functions: {', '.join(functions)}")
|
|
||||||
if not gpio_functions:
|
|
||||||
raise RuntimeError(f"{pin} does not provide a GPIO input function")
|
|
||||||
|
|
||||||
return gpio_functions[0]
|
|
||||||
|
|
||||||
|
|
||||||
def main():
|
|
||||||
gpio_function = find_gpio_function(PIN)
|
|
||||||
pinmap.set_pin_function(PIN, gpio_function)
|
|
||||||
voltage_input = gpio.GPIO(gpio_function, gpio.Mode.IN)
|
|
||||||
|
|
||||||
print(f"Reading {PIN} through {gpio_function}")
|
|
||||||
print("P21 only reports LOW/HIGH; displayed voltage is an estimate.")
|
|
||||||
print("Press the MaixCAM exit key to stop.")
|
|
||||||
|
|
||||||
while not app.need_exit():
|
|
||||||
level = voltage_input.value()
|
|
||||||
estimated_voltage = IO_HIGH_VOLTAGE if level else 0.0
|
|
||||||
state = "HIGH" if level else "LOW"
|
|
||||||
print(
|
|
||||||
f"{PIN}: level={level}, state={state}, "
|
|
||||||
f"estimated_voltage={estimated_voltage:.1f} V"
|
|
||||||
)
|
|
||||||
time.sleep_ms(SAMPLE_INTERVAL_MS)
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
try:
|
|
||||||
main()
|
|
||||||
except Exception as error:
|
|
||||||
print(f"P21 voltage detection failed: {error}")
|
|
||||||
print("Check that this MaixCAM model exposes P21 as a GPIO pin.")
|
|
||||||
raise
|
|
||||||
@@ -1,139 +0,0 @@
|
|||||||
import json
|
|
||||||
import sys
|
|
||||||
import types
|
|
||||||
import unittest
|
|
||||||
|
|
||||||
|
|
||||||
class _FakeTime:
|
|
||||||
@staticmethod
|
|
||||||
def sleep(_seconds):
|
|
||||||
pass
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def sleep_ms(_milliseconds):
|
|
||||||
pass
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def ticks_ms():
|
|
||||||
return 0
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def ticks_diff(left, right):
|
|
||||||
return left - right
|
|
||||||
|
|
||||||
|
|
||||||
class _FakeLogger:
|
|
||||||
def debug(self, *_args, **_kwargs):
|
|
||||||
pass
|
|
||||||
|
|
||||||
def info(self, *_args, **_kwargs):
|
|
||||||
pass
|
|
||||||
|
|
||||||
def warning(self, *_args, **_kwargs):
|
|
||||||
pass
|
|
||||||
|
|
||||||
def error(self, *_args, **_kwargs):
|
|
||||||
pass
|
|
||||||
|
|
||||||
|
|
||||||
class _FakeSocket:
|
|
||||||
def __init__(self, recv_data=b""):
|
|
||||||
self.recv_data = recv_data
|
|
||||||
self.closed = False
|
|
||||||
|
|
||||||
def close(self):
|
|
||||||
self.closed = True
|
|
||||||
|
|
||||||
def recv(self, _size, *_flags):
|
|
||||||
return self.recv_data
|
|
||||||
|
|
||||||
|
|
||||||
class _StopAfterCallback:
|
|
||||||
def __init__(self):
|
|
||||||
self.stopped = False
|
|
||||||
|
|
||||||
def is_set(self):
|
|
||||||
return self.stopped
|
|
||||||
|
|
||||||
|
|
||||||
maix_module = types.ModuleType("maix")
|
|
||||||
maix_module.time = _FakeTime
|
|
||||||
maix_module.network = types.SimpleNamespace()
|
|
||||||
maix_module.err = types.SimpleNamespace()
|
|
||||||
sys.modules.setdefault("maix", maix_module)
|
|
||||||
sys.modules.setdefault("ujson", json)
|
|
||||||
|
|
||||||
netcore_module = types.ModuleType("archery_netcore")
|
|
||||||
netcore_module.get_config = lambda: {"SERVER_IP": "127.0.0.1", "SERVER_PORT": 1234}
|
|
||||||
netcore_module.parse_packet = lambda _packet: (0, {})
|
|
||||||
netcore_module.make_packet = lambda *_args, **_kwargs: b""
|
|
||||||
netcore_module.actions_for_inner_cmd = lambda *_args, **_kwargs: []
|
|
||||||
sys.modules["archery_netcore"] = netcore_module
|
|
||||||
|
|
||||||
hardware_module = types.ModuleType("hardware")
|
|
||||||
hardware_module.hardware_manager = types.SimpleNamespace()
|
|
||||||
sys.modules["hardware"] = hardware_module
|
|
||||||
|
|
||||||
power_module = types.ModuleType("power")
|
|
||||||
power_module.get_bus_voltage = lambda: 0
|
|
||||||
power_module.voltage_to_percent = lambda _voltage: 0
|
|
||||||
sys.modules["power"] = power_module
|
|
||||||
|
|
||||||
import logger_manager
|
|
||||||
import wifi
|
|
||||||
import network
|
|
||||||
|
|
||||||
|
|
||||||
class WiFiFailoverTests(unittest.TestCase):
|
|
||||||
def setUp(self):
|
|
||||||
logger_manager.logger_manager._logger = _FakeLogger()
|
|
||||||
|
|
||||||
def test_monitor_switches_when_sta_association_is_lost(self):
|
|
||||||
manager = wifi.wifi_manager
|
|
||||||
stop_event = _StopAfterCallback()
|
|
||||||
callbacks = []
|
|
||||||
|
|
||||||
manager._wifi_socket = _FakeSocket()
|
|
||||||
manager._wifi_quality_stop_event = stop_event
|
|
||||||
manager._network_type_callback = lambda: "wifi"
|
|
||||||
manager.is_sta_associated = lambda: False
|
|
||||||
manager._get_wifi_rssi_dbm = lambda: None
|
|
||||||
|
|
||||||
def on_poor_quality():
|
|
||||||
callbacks.append(True)
|
|
||||||
stop_event.stopped = True
|
|
||||||
|
|
||||||
manager._on_poor_quality_callback = on_poor_quality
|
|
||||||
manager._quality_monitor_loop()
|
|
||||||
|
|
||||||
self.assertEqual(callbacks, [True])
|
|
||||||
self.assertIsNone(manager.last_wifi_rtt_ms)
|
|
||||||
|
|
||||||
def test_tls_connection_check_rejects_lost_sta_association(self):
|
|
||||||
manager = network.network_manager
|
|
||||||
sock = _FakeSocket()
|
|
||||||
wifi.wifi_manager._wifi_socket = sock
|
|
||||||
wifi.wifi_manager._wifi_connected = True
|
|
||||||
wifi.wifi_manager._wifi_ip = "192.168.1.2"
|
|
||||||
wifi.wifi_manager.is_sta_associated = lambda: False
|
|
||||||
manager._tcp_connected = True
|
|
||||||
|
|
||||||
self.assertFalse(manager._check_wifi_connection())
|
|
||||||
self.assertTrue(sock.closed)
|
|
||||||
self.assertIsNone(wifi.wifi_manager.wifi_socket)
|
|
||||||
self.assertFalse(manager.tcp_connected)
|
|
||||||
|
|
||||||
def test_receive_eof_marks_wifi_tcp_disconnected(self):
|
|
||||||
manager = network.network_manager
|
|
||||||
sock = _FakeSocket(recv_data=b"")
|
|
||||||
wifi.wifi_manager._wifi_socket = sock
|
|
||||||
manager._tcp_connected = True
|
|
||||||
|
|
||||||
self.assertEqual(manager.receive_tcp_data_via_wifi(), b"")
|
|
||||||
self.assertTrue(sock.closed)
|
|
||||||
self.assertIsNone(wifi.wifi_manager.wifi_socket)
|
|
||||||
self.assertFalse(manager.tcp_connected)
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
unittest.main()
|
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
from maix import camera, display, image, nn, app
|
||||||
|
|
||||||
|
# 1. 初始化模型 (请确保模型文件 .mud 路径正确)
|
||||||
|
detector = nn.YOLOv5(model="/root/model_279350.mud", dual_buff=True)
|
||||||
|
|
||||||
|
# 2. 初始化摄像头,分辨率与模型输入匹配
|
||||||
|
cam = camera.Camera(detector.input_width(), detector.input_height(), detector.input_format())
|
||||||
|
disp = display.Display()
|
||||||
|
|
||||||
|
# 3. 主循环:实时检测与显示
|
||||||
|
while not app.need_exit():
|
||||||
|
img = cam.read() # 从摄像头读取一帧
|
||||||
|
objs = detector.detect(img, conf_th=0.5, iou_th=0.45) # 执行YOLO11推理
|
||||||
|
for obj in objs: # 绘制所有检测到的目标
|
||||||
|
img.draw_rect(obj.x, obj.y, obj.w, obj.h, color=image.COLOR_RED)
|
||||||
|
msg = f'{detector.labels[obj.class_id]}: {obj.score:.2f}'
|
||||||
|
img.draw_string(obj.x, obj.y, msg, color=image.COLOR_RED)
|
||||||
|
disp.show(img) # 更新屏幕显示
|
||||||
@@ -0,0 +1,209 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
"""
|
||||||
|
摄像头实时 YOLOv5 简易测试脚本。
|
||||||
|
|
||||||
|
特点:
|
||||||
|
- 完全独立脚本,直接 python test/test_yolo_camera_simple.py 运行,不需要传参。
|
||||||
|
- 不 import config,不依赖项目模块。
|
||||||
|
- 直接调用 maix.nn.YOLOv5(model=..., dual_buff=False)。
|
||||||
|
- camera.read() 得到的 Maix image 直接送 det.detect()。
|
||||||
|
- 在画面上画检测框、类别、置信度,并显示到屏幕。
|
||||||
|
|
||||||
|
运行环境:MaixCAM / MaixPy。
|
||||||
|
"""
|
||||||
|
|
||||||
|
import os
|
||||||
|
|
||||||
|
|
||||||
|
CAMERA_WIDTH = 640
|
||||||
|
CAMERA_HEIGHT = 480
|
||||||
|
# 默认与主项目 config.TRIANGLE_YOLO_MODEL_PATH 一致(勿用 /root/yolo26_int8.mud,那是占位路径)
|
||||||
|
_MODEL_DEFAULT = "/maixapp/apps/t11/model_270139.mud"
|
||||||
|
try:
|
||||||
|
import config as _cfg
|
||||||
|
|
||||||
|
MODEL_PATH = getattr(_cfg, "TRIANGLE_YOLO_MODEL_PATH", _MODEL_DEFAULT) or _MODEL_DEFAULT
|
||||||
|
except Exception:
|
||||||
|
MODEL_PATH = _MODEL_DEFAULT
|
||||||
|
CONF_TH = 0.7
|
||||||
|
IOU_TH = 0.45
|
||||||
|
# native: Maix detect 返回框已映射到 camera.read() 图像坐标;letterbox: 需要从网络输入坐标反算
|
||||||
|
COORD_MODE = "native"
|
||||||
|
# 只用于 DRAW_ONLY_CLASS_IDS=True 时过滤显示;默认画所有框
|
||||||
|
CLASS_IDS = (0,)
|
||||||
|
DRAW_ONLY_CLASS_IDS = False # True=只画 CLASS_IDS 里的类别;False=画所有 YOLO 返回框
|
||||||
|
|
||||||
|
|
||||||
|
def _det_obj_class_id(o):
|
||||||
|
for key in ("class_id", "cls", "label", "category", "cat_id", "id"):
|
||||||
|
if hasattr(o, key):
|
||||||
|
v = getattr(o, key)
|
||||||
|
if v is None:
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
return int(float(v))
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
continue
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _det_obj_from_seq(t):
|
||||||
|
if not isinstance(t, (list, tuple)) or len(t) < 6:
|
||||||
|
return None
|
||||||
|
|
||||||
|
class Box:
|
||||||
|
pass
|
||||||
|
|
||||||
|
b = Box()
|
||||||
|
b.x = float(t[0])
|
||||||
|
b.y = float(t[1])
|
||||||
|
b.w = float(t[2])
|
||||||
|
b.h = float(t[3])
|
||||||
|
b.score = float(t[4])
|
||||||
|
b.class_id = int(float(t[5]))
|
||||||
|
return b
|
||||||
|
|
||||||
|
|
||||||
|
def _normalize_objs(objs):
|
||||||
|
out = []
|
||||||
|
for o in objs or []:
|
||||||
|
if isinstance(o, (list, tuple)):
|
||||||
|
m = _det_obj_from_seq(o)
|
||||||
|
if m is not None:
|
||||||
|
out.append(m)
|
||||||
|
else:
|
||||||
|
out.append(o)
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def _letterbox_net_to_src_xyxy(x, y, w, h, src_w, src_h, net_w, net_h):
|
||||||
|
scale = min(net_w / float(src_w), net_h / float(src_h))
|
||||||
|
new_w = src_w * scale
|
||||||
|
new_h = src_h * scale
|
||||||
|
pad_x = (net_w - new_w) * 0.5
|
||||||
|
pad_y = (net_h - new_h) * 0.5
|
||||||
|
x0 = (x - pad_x) / scale
|
||||||
|
y0 = (y - pad_y) / scale
|
||||||
|
x1 = (x + w - pad_x) / scale
|
||||||
|
y1 = (y + h - pad_y) / scale
|
||||||
|
return x0, y0, x1, y1
|
||||||
|
|
||||||
|
|
||||||
|
def _det_to_src_xyxy(o, coord_mode, src_w, src_h, net_w, net_h):
|
||||||
|
x = float(getattr(o, "x", 0.0))
|
||||||
|
y = float(getattr(o, "y", 0.0))
|
||||||
|
w = float(getattr(o, "w", 0.0))
|
||||||
|
h = float(getattr(o, "h", 0.0))
|
||||||
|
if coord_mode in ("native", "source", "camera", "full"):
|
||||||
|
return x, y, x + w, y + h
|
||||||
|
return _letterbox_net_to_src_xyxy(x, y, w, h, src_w, src_h, net_w, net_h)
|
||||||
|
|
||||||
|
|
||||||
|
def _clip_xywh(x0, y0, x1, y1, src_w, src_h):
|
||||||
|
x0 = max(0, min(int(round(x0)), src_w - 1))
|
||||||
|
y0 = max(0, min(int(round(y0)), src_h - 1))
|
||||||
|
x1 = max(x0 + 1, min(int(round(x1)), src_w))
|
||||||
|
y1 = max(y0 + 1, min(int(round(y1)), src_h))
|
||||||
|
return x0, y0, x1 - x0, y1 - y0
|
||||||
|
|
||||||
|
|
||||||
|
def _label(det, cid):
|
||||||
|
labels = getattr(det, "labels", None)
|
||||||
|
if labels is None:
|
||||||
|
return str(cid)
|
||||||
|
try:
|
||||||
|
return str(labels[int(cid)])
|
||||||
|
except Exception:
|
||||||
|
return str(cid)
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
from maix import camera, display, nn, time, image
|
||||||
|
|
||||||
|
if not MODEL_PATH or not os.path.isfile(MODEL_PATH):
|
||||||
|
print("[ERR] 模型文件不存在:", MODEL_PATH)
|
||||||
|
return
|
||||||
|
|
||||||
|
print("[INFO] 初始化 YOLO 模型:", MODEL_PATH)
|
||||||
|
det = nn.YOLOv26(model=MODEL_PATH, dual_buff=False)
|
||||||
|
net_w = int(det.input_width())
|
||||||
|
net_h = int(det.input_height())
|
||||||
|
print(
|
||||||
|
"[INFO] net_in=%dx%d conf=%.2f iou=%.2f coord=%s class_ids=%s"
|
||||||
|
% (net_w, net_h, CONF_TH, IOU_TH, COORD_MODE, str(CLASS_IDS))
|
||||||
|
)
|
||||||
|
|
||||||
|
print("[INFO] 初始化摄像头: %dx%d" % (CAMERA_WIDTH, CAMERA_HEIGHT))
|
||||||
|
cam = camera.Camera(CAMERA_WIDTH, CAMERA_HEIGHT)
|
||||||
|
disp = display.Display()
|
||||||
|
|
||||||
|
color_cycle = []
|
||||||
|
for name in ("RED", "GREEN", "BLUE", "ORANGE", "YELLOW", "CYAN", "MAGENTA"):
|
||||||
|
c = getattr(image, "COLOR_" + name, None)
|
||||||
|
if c is not None:
|
||||||
|
color_cycle.append(c)
|
||||||
|
if not color_cycle:
|
||||||
|
color_cycle = [getattr(image, "COLOR_RED", 0)]
|
||||||
|
|
||||||
|
frame_idx = 0
|
||||||
|
last_log_ms = time.ticks_ms()
|
||||||
|
fps_count = 0
|
||||||
|
|
||||||
|
while True:
|
||||||
|
frame = cam.read()
|
||||||
|
src_w = frame.width()
|
||||||
|
src_h = frame.height()
|
||||||
|
|
||||||
|
t0 = time.ticks_ms()
|
||||||
|
raw = det.detect(frame, conf_th=CONF_TH, iou_th=IOU_TH)
|
||||||
|
detect_ms = time.ticks_ms() - t0
|
||||||
|
objs = _normalize_objs(raw if raw is not None else [])
|
||||||
|
|
||||||
|
draw_count = 0
|
||||||
|
for i, o in enumerate(objs):
|
||||||
|
cid = _det_obj_class_id(o)
|
||||||
|
if cid is None:
|
||||||
|
cid = -1
|
||||||
|
if DRAW_ONLY_CLASS_IDS and cid not in CLASS_IDS:
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
score = float(getattr(o, "score", 0.0))
|
||||||
|
except Exception:
|
||||||
|
score = 0.0
|
||||||
|
|
||||||
|
x0, y0, x1, y1 = _det_to_src_xyxy(o, COORD_MODE, src_w, src_h, net_w, net_h)
|
||||||
|
ix, iy, iw, ih = _clip_xywh(x0, y0, x1, y1, src_w, src_h)
|
||||||
|
col = color_cycle[cid % len(color_cycle)] if cid >= 0 else color_cycle[0]
|
||||||
|
frame.draw_rect(ix, iy, iw, ih, color=col)
|
||||||
|
frame.draw_string(ix, max(0, iy - 16), "%s %.2f" % (_label(det, cid), score), color=col)
|
||||||
|
draw_count += 1
|
||||||
|
|
||||||
|
frame.draw_string(4, 4, "YOLO boxes:%d draw:%d %dms" % (len(objs), draw_count, detect_ms), color=color_cycle[0])
|
||||||
|
disp.show(frame)
|
||||||
|
|
||||||
|
frame_idx += 1
|
||||||
|
fps_count += 1
|
||||||
|
now = time.ticks_ms()
|
||||||
|
if now - last_log_ms >= 1000:
|
||||||
|
print(
|
||||||
|
"[INFO] frame=%d fps=%d raw_boxes=%d draw_boxes=%d detect_ms=%d"
|
||||||
|
% (frame_idx, fps_count, len(objs), draw_count, detect_ms)
|
||||||
|
)
|
||||||
|
fps_count = 0
|
||||||
|
last_log_ms = now
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
try:
|
||||||
|
main()
|
||||||
|
except KeyboardInterrupt:
|
||||||
|
print("[INFO] exit")
|
||||||
|
except Exception as e:
|
||||||
|
print("[ERR]", e)
|
||||||
|
try:
|
||||||
|
import traceback
|
||||||
|
|
||||||
|
traceback.print_exc()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
from maix import image, nn, display
|
||||||
|
|
||||||
|
# 1. 加载模型
|
||||||
|
detector = nn.YOLOv8(model="/root/279350.mud", dual_buff=False)
|
||||||
|
# 2. 加载指定图片(根据模型输入尺寸自动缩放宽高)
|
||||||
|
img = image.load("/root/tes.jpg")
|
||||||
|
if img is None:
|
||||||
|
raise FileNotFoundError("图片加载失败,请检查路径")
|
||||||
|
|
||||||
|
# 3. 调整图片尺寸到模型输入要求(可选,detect内部会处理,但提前缩放可提高速度)
|
||||||
|
# img = img.resize(detector.input_width(), detector.input_height())
|
||||||
|
|
||||||
|
# 4. 检测
|
||||||
|
objs = detector.detect(img, conf_th=0.5, iou_th=0.45)
|
||||||
|
|
||||||
|
# 5. 在图片上绘制结果
|
||||||
|
for obj in objs:
|
||||||
|
img.draw_rect(obj.x, obj.y, obj.w, obj.h, color=image.COLOR_RED)
|
||||||
|
msg = f'{detector.labels[obj.class_id]}: {obj.score:.2f}'
|
||||||
|
img.draw_string(obj.x, obj.y, msg, color=image.COLOR_RED)
|
||||||
|
|
||||||
|
# 6. 显示结果(如果设备有屏幕)
|
||||||
|
disp = display.Display()
|
||||||
|
disp.show(img)
|
||||||
|
|
||||||
|
# 7. 保存结果(可选)
|
||||||
|
img.save("/root/result.jpg")
|
||||||
|
|
||||||
|
print("识别完成,结果已显示并保存为 result.jpg")
|
||||||
+171
-4
@@ -22,6 +22,143 @@ def _log(msg):
|
|||||||
pass
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
def _read_triangle_direction_cfg():
|
||||||
|
"""读取 config 中三角形方向/中心距校验参数。"""
|
||||||
|
try:
|
||||||
|
import config as cfg
|
||||||
|
return {
|
||||||
|
"enable": bool(getattr(cfg, "TRIANGLE_DIRECTION_VALIDATE_ENABLE", True)),
|
||||||
|
"min_pass": int(getattr(cfg, "TRIANGLE_DIRECTION_MIN_PASS", 3)),
|
||||||
|
"dot_min": float(getattr(cfg, "TRIANGLE_DIRECTION_DOT_MIN", 0.0)),
|
||||||
|
"to_center_dot_min": float(
|
||||||
|
getattr(cfg, "TRIANGLE_DIRECTION_TO_CENTER_DOT_MIN", 0.35)
|
||||||
|
),
|
||||||
|
"center_dist_enable": bool(
|
||||||
|
getattr(cfg, "TRIANGLE_CENTER_DISTANCE_VALIDATE_ENABLE", True)
|
||||||
|
),
|
||||||
|
"center_dist_tol": float(
|
||||||
|
getattr(cfg, "TRIANGLE_CENTER_DISTANCE_RATIO_TOL", 0.45)
|
||||||
|
),
|
||||||
|
}
|
||||||
|
except Exception:
|
||||||
|
return {
|
||||||
|
"enable": True,
|
||||||
|
"min_pass": 3,
|
||||||
|
"dot_min": 0.0,
|
||||||
|
"to_center_dot_min": 0.35,
|
||||||
|
"center_dist_enable": True,
|
||||||
|
"center_dist_tol": 0.45,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _quad_combo_orient_penalty(cands_4):
|
||||||
|
"""
|
||||||
|
四点组合评分用的方向惩罚(原 _score_quad 内 orient_pen 逻辑)。
|
||||||
|
TRIANGLE_DIRECTION_VALIDATE_ENABLE=False 时调用方应跳过(不加罚)。
|
||||||
|
"""
|
||||||
|
orient_pen = 0.0
|
||||||
|
orient_vote = []
|
||||||
|
for c in cands_4:
|
||||||
|
cen = np.array(c["center_px"], dtype=np.float32)
|
||||||
|
rpt = np.array(c["right_pt"], dtype=np.float32)
|
||||||
|
vx = float(cen[0] - rpt[0])
|
||||||
|
vy = float(cen[1] - rpt[1])
|
||||||
|
if abs(vx) < 1e-6 or abs(vy) < 1e-6:
|
||||||
|
orient_pen += 1.0
|
||||||
|
orient_vote.append(None)
|
||||||
|
continue
|
||||||
|
if abs(vx) < abs(vy) * 0.15 or abs(vy) < abs(vx) * 0.15:
|
||||||
|
orient_pen += 0.5
|
||||||
|
if vx > 0 and vy > 0:
|
||||||
|
orient_vote.append(0)
|
||||||
|
elif vx < 0 and vy > 0:
|
||||||
|
orient_vote.append(1)
|
||||||
|
elif vx > 0 and vy < 0:
|
||||||
|
orient_vote.append(2)
|
||||||
|
else:
|
||||||
|
orient_vote.append(3)
|
||||||
|
valid_votes = [v for v in orient_vote if v is not None]
|
||||||
|
if valid_votes:
|
||||||
|
from collections import Counter
|
||||||
|
vc = Counter(valid_votes)
|
||||||
|
orient_pen += max(0, max(vc.values()) - 1) * 0.8
|
||||||
|
return orient_pen
|
||||||
|
|
||||||
|
|
||||||
|
def _marker_inward_unit(marker):
|
||||||
|
"""从直角顶点指向三角内部的单位向量;marker['center'] 为直角顶点。"""
|
||||||
|
right = np.array(marker["center"], dtype=np.float64)
|
||||||
|
corners = marker.get("corners")
|
||||||
|
if not corners or len(corners) < 3:
|
||||||
|
return None
|
||||||
|
cen = np.mean(np.array(corners, dtype=np.float64), axis=0)
|
||||||
|
inv = cen - right
|
||||||
|
n = float(np.linalg.norm(inv))
|
||||||
|
if n < 1e-6:
|
||||||
|
return None
|
||||||
|
return inv / n
|
||||||
|
|
||||||
|
|
||||||
|
def _validate_triangle_direction(marker_centers, tri_markers, cfg):
|
||||||
|
"""
|
||||||
|
校验:四角到候选靶心距离近似一致;各真实黑三角朝向靶心。
|
||||||
|
仅统计 tri_markers 中真实检出的角(不含几何补全的虚拟点)。
|
||||||
|
Returns:
|
||||||
|
(ok: bool, reason: str)
|
||||||
|
"""
|
||||||
|
if not cfg.get("enable", True):
|
||||||
|
return True, ""
|
||||||
|
|
||||||
|
pts = np.array(marker_centers, dtype=np.float64).reshape(-1, 2)
|
||||||
|
if len(pts) < 3:
|
||||||
|
return True, ""
|
||||||
|
|
||||||
|
quad_center = np.mean(pts, axis=0)
|
||||||
|
|
||||||
|
if cfg.get("center_dist_enable", True) and len(pts) >= 3:
|
||||||
|
dists = np.linalg.norm(pts - quad_center, axis=1)
|
||||||
|
mean_d = float(np.mean(dists))
|
||||||
|
if mean_d > 1e-6:
|
||||||
|
ratio = (float(np.max(dists)) - float(np.min(dists))) / mean_d
|
||||||
|
tol = float(cfg.get("center_dist_tol", 0.45))
|
||||||
|
if ratio > tol:
|
||||||
|
return False, f"center_dist_ratio={ratio:.2f}>{tol:.2f}"
|
||||||
|
|
||||||
|
dot_need = max(
|
||||||
|
float(cfg.get("dot_min", 0.0)),
|
||||||
|
float(cfg.get("to_center_dot_min", 0.35)),
|
||||||
|
)
|
||||||
|
pass_n = 0
|
||||||
|
check_n = 0
|
||||||
|
for m in tri_markers or []:
|
||||||
|
if m.get("center") is None:
|
||||||
|
continue
|
||||||
|
check_n += 1
|
||||||
|
right = np.array(m["center"], dtype=np.float64)
|
||||||
|
to_center = quad_center - right
|
||||||
|
nc = float(np.linalg.norm(to_center))
|
||||||
|
if nc < 1e-6:
|
||||||
|
continue
|
||||||
|
inward = _marker_inward_unit(m)
|
||||||
|
if inward is None:
|
||||||
|
continue
|
||||||
|
dot_tc = float(np.dot(inward, to_center / nc))
|
||||||
|
if dot_tc >= dot_need:
|
||||||
|
pass_n += 1
|
||||||
|
|
||||||
|
if check_n == 0:
|
||||||
|
return True, ""
|
||||||
|
|
||||||
|
min_pass = int(cfg.get("min_pass", 3))
|
||||||
|
min_pass = max(1, min(min_pass, check_n))
|
||||||
|
if pass_n < min_pass:
|
||||||
|
return False, (
|
||||||
|
f"direction_pass={pass_n}/{check_n} need>={min_pass} "
|
||||||
|
f"(dot>={dot_need:.2f})"
|
||||||
|
)
|
||||||
|
return True, ""
|
||||||
|
|
||||||
|
|
||||||
def _gray_suppress_bright_by_v(img_rgb, v_above: int):
|
def _gray_suppress_bright_by_v(img_rgb, v_above: int):
|
||||||
"""
|
"""
|
||||||
RGB 输入:在 HSV 的 V 上,将亮度 >= v_above 的像素灰度置为 255。
|
RGB 输入:在 HSV 的 V 上,将亮度 >= v_above 的像素灰度置为 255。
|
||||||
@@ -224,7 +361,7 @@ def detect_triangle_markers(
|
|||||||
blackhat_kernel_frac = 0.018
|
blackhat_kernel_frac = 0.018
|
||||||
try:
|
try:
|
||||||
import config as _tcfg
|
import config as _tcfg
|
||||||
_timing_log = bool(getattr(_tcfg, "TRIANGLE_TIMING_LOG", True))
|
_timing_log = bool(getattr(_tcfg, "ARCHERY_TIMING_ENABLE", True)) and bool(getattr(_tcfg, "TRIANGLE_TIMING_LOG", True))
|
||||||
except Exception:
|
except Exception:
|
||||||
_timing_log = True
|
_timing_log = True
|
||||||
|
|
||||||
@@ -622,6 +759,8 @@ def detect_triangle_markers(
|
|||||||
bot_pair = sorted(by_y[2:], key=lambda i: pts_4[i][0])
|
bot_pair = sorted(by_y[2:], key=lambda i: pts_4[i][0])
|
||||||
return top_pair[0], bot_pair[0], bot_pair[1], top_pair[1]
|
return top_pair[0], bot_pair[0], bot_pair[1], top_pair[1]
|
||||||
|
|
||||||
|
_dir_cfg_combo = _read_triangle_direction_cfg()
|
||||||
|
|
||||||
def _score_quad(cands_4):
|
def _score_quad(cands_4):
|
||||||
pts = [np.array(c["center_px"]) for c in cands_4]
|
pts = [np.array(c["center_px"]) for c in cands_4]
|
||||||
legs = [c["avg_leg"] for c in cands_4]
|
legs = [c["avg_leg"] for c in cands_4]
|
||||||
@@ -641,7 +780,13 @@ def detect_triangle_markers(
|
|||||||
med_l = float(np.median(legs))
|
med_l = float(np.median(legs))
|
||||||
leg_dev = max(abs(l - med_l) / (med_l + 1e-6) for l in legs)
|
leg_dev = max(abs(l - med_l) / (med_l + 1e-6) for l in legs)
|
||||||
|
|
||||||
score = (diag_ratio - 1.0) * 3.0 + (h_ratio - 1.0) + (v_ratio - 1.0) + leg_dev * 2.0
|
orient_pen = (
|
||||||
|
_quad_combo_orient_penalty(cands_4)
|
||||||
|
if _dir_cfg_combo.get("enable", True)
|
||||||
|
else 0.0
|
||||||
|
)
|
||||||
|
|
||||||
|
score = (diag_ratio - 1.0) * 3.0 + (h_ratio - 1.0) + (v_ratio - 1.0) + leg_dev * 2.0 + orient_pen
|
||||||
return score, (tl, bl, br, tr)
|
return score, (tl, bl, br, tr)
|
||||||
|
|
||||||
assigned = None
|
assigned = None
|
||||||
@@ -932,6 +1077,8 @@ def _assign_marker_ids_from_filtered(filtered, verbose=True):
|
|||||||
bot_pair = sorted(by_y[2:], key=lambda i: pts_4[i][0])
|
bot_pair = sorted(by_y[2:], key=lambda i: pts_4[i][0])
|
||||||
return top_pair[0], bot_pair[0], bot_pair[1], top_pair[1]
|
return top_pair[0], bot_pair[0], bot_pair[1], top_pair[1]
|
||||||
|
|
||||||
|
_dir_cfg_combo = _read_triangle_direction_cfg()
|
||||||
|
|
||||||
def _score_quad(cands_4):
|
def _score_quad(cands_4):
|
||||||
pts = [np.array(c["center_px"]) for c in cands_4]
|
pts = [np.array(c["center_px"]) for c in cands_4]
|
||||||
legs = [c["avg_leg"] for c in cands_4]
|
legs = [c["avg_leg"] for c in cands_4]
|
||||||
@@ -947,7 +1094,12 @@ def _assign_marker_ids_from_filtered(filtered, verbose=True):
|
|||||||
v_ratio = max(s_left, s_right) / (min(s_left, s_right) + 1e-6)
|
v_ratio = max(s_left, s_right) / (min(s_left, s_right) + 1e-6)
|
||||||
med_l = float(np.median(legs))
|
med_l = float(np.median(legs))
|
||||||
leg_dev = max(abs(l - med_l) / (med_l + 1e-6) for l in legs)
|
leg_dev = max(abs(l - med_l) / (med_l + 1e-6) for l in legs)
|
||||||
score = (diag_ratio - 1.0) * 3.0 + (h_ratio - 1.0) + (v_ratio - 1.0) + leg_dev * 2.0
|
orient_pen = (
|
||||||
|
_quad_combo_orient_penalty(cands_4)
|
||||||
|
if _dir_cfg_combo.get("enable", True)
|
||||||
|
else 0.0
|
||||||
|
)
|
||||||
|
score = (diag_ratio - 1.0) * 3.0 + (h_ratio - 1.0) + (v_ratio - 1.0) + leg_dev * 2.0 + orient_pen
|
||||||
return score, (tl, bl, br, tr)
|
return score, (tl, bl, br, tr)
|
||||||
|
|
||||||
assigned = None
|
assigned = None
|
||||||
@@ -1113,7 +1265,7 @@ def try_triangle_scoring(
|
|||||||
|
|
||||||
try:
|
try:
|
||||||
import config as _cfg_tl
|
import config as _cfg_tl
|
||||||
_try_timing_log = bool(getattr(_cfg_tl, "TRIANGLE_TIMING_LOG", True))
|
_try_timing_log = bool(getattr(_cfg_tl, "ARCHERY_TIMING_ENABLE", True)) and bool(getattr(_cfg_tl, "TRIANGLE_TIMING_LOG", True))
|
||||||
_crop_min_side = int(getattr(_cfg_tl, "TRIANGLE_CROP_ROI_MIN_SIDE_PX", 64))
|
_crop_min_side = int(getattr(_cfg_tl, "TRIANGLE_CROP_ROI_MIN_SIDE_PX", 64))
|
||||||
except Exception:
|
except Exception:
|
||||||
_try_timing_log = True
|
_try_timing_log = True
|
||||||
@@ -1733,6 +1885,21 @@ def try_triangle_scoring(
|
|||||||
"is_virtual": bool(_is_virtual),
|
"is_virtual": bool(_is_virtual),
|
||||||
})
|
})
|
||||||
|
|
||||||
|
# ---------- 方向 / 中心距校验(config.TRIANGLE_DIRECTION_*) ----------
|
||||||
|
_dir_cfg = _read_triangle_direction_cfg()
|
||||||
|
_dir_ok, _dir_reason = _validate_triangle_direction(
|
||||||
|
marker_centers, tri_markers, _dir_cfg
|
||||||
|
)
|
||||||
|
if not _dir_ok:
|
||||||
|
_log(f"[TRI] 方向校验失败: {_dir_reason}")
|
||||||
|
if _try_timing_log:
|
||||||
|
_log(
|
||||||
|
f"[TRI] timing_ms(try_triangle): {_tri_yolo_part} "
|
||||||
|
f"geometry={(time.perf_counter() - _t_seg) * 1000:.1f} "
|
||||||
|
f"total_try={(time.perf_counter() - _t_try0) * 1000:.1f} (方向校验失败)"
|
||||||
|
)
|
||||||
|
return out
|
||||||
|
|
||||||
# ---------- 结果有效性校验(防 nan/inf 与退化角点) ----------
|
# ---------- 结果有效性校验(防 nan/inf 与退化角点) ----------
|
||||||
try:
|
try:
|
||||||
import config as _cfg
|
import config as _cfg
|
||||||
|
|||||||
-38
@@ -1,38 +0,0 @@
|
|||||||
# 1.2.0 开始使用C++编译成.so,替换部分代码
|
|
||||||
# 1.2.1 ota使用加密包
|
|
||||||
# 1.2.2 支持wifi ota,并且设定时区,并使用单独线程保存图片
|
|
||||||
# 1.2.3 修改ADC_TRIGGER_THRESHOLD 为2300,支持上传日志到服务器
|
|
||||||
# 1.2.4 修改ADC_TRIGGER_THRESHOLD 为3000,并默认关闭摄像头的显示,并把ADC的采样间隔从50ms降低到10ms
|
|
||||||
# 1.2.5 支持空气传感器采样,并默认关闭日志。优化断网时的发送队列丢消息问题,解决 WiFi 断线检测不可靠问题。
|
|
||||||
# 1.2.6 在链接 wifi 前先判断 wifi 的可用性,假如不可用,则不落盘。增加日志批量压缩上传功能
|
|
||||||
# 1.2.7 修复OTA失败的bug, 空气压力传感器的阈值是2500
|
|
||||||
# 1.2.8 (1) 加快 wifi 下数据传输的速度。(2) 调整射箭时处理的逻辑,优先上报数据,再存照片之类的操作。(3)假如是用户打开激光的,射箭触发后不再关闭激光,因为是调瞄阶段
|
|
||||||
# 1.2.9 增加电源板的控制和自动关机的功能
|
|
||||||
# 1.2.10 config formal
|
|
||||||
# 1.2.11 增加三角形的单应性算法,适配对应的靶纸
|
|
||||||
# 1.2.110 关掉了黑色三角形算法,只用于测试
|
|
||||||
# 1.2.13 修改wifi连接
|
|
||||||
# 1.2.14 修改了icc登录部分
|
|
||||||
# 2.15.3 新版本ota,去除ai算环数方法
|
|
||||||
# 2.15.4 更新版本号
|
|
||||||
# 2.15.5 打印ota进度
|
|
||||||
# 2.15.6 更新版本号
|
|
||||||
# 2.15.7 更新版本号
|
|
||||||
# 2.15.8 启动不加载预加载yolo
|
|
||||||
# 2.15.9 20cm
|
|
||||||
# 2.15.10 不保存图片
|
|
||||||
# 2.15.11 优化内存
|
|
||||||
# 2.15.12 优化算法
|
|
||||||
# 2.15.13 优化算法
|
|
||||||
# 2.15.14 优化算法
|
|
||||||
# 2.15.15 优化wifi连接
|
|
||||||
# 2.15.16 修复wifi连接问题
|
|
||||||
# 2.15.17 修复wifi连接问题
|
|
||||||
# 2.15.18 wifi连接成功重新登录
|
|
||||||
# 2.15.20 加了充电关机,激光也同时关闭
|
|
||||||
# 2.15.21 测试4g 扩大了缓存池和改了心跳时间
|
|
||||||
# 2.15.22 修复了4g网络和wifi切换问题
|
|
||||||
# 2.15.23 合并充电关机与稳定版网络修复
|
|
||||||
# 2.15.24 空改测试
|
|
||||||
# 2.15.25 修复整合后关机失败和ota格式更新问题
|
|
||||||
# 2.15.26
|
|
||||||
+23
-1
@@ -4,6 +4,28 @@
|
|||||||
应用版本号
|
应用版本号
|
||||||
每次 OTA 更新时,只需要更新这个文件中的版本号
|
每次 OTA 更新时,只需要更新这个文件中的版本号
|
||||||
"""
|
"""
|
||||||
VERSION = '2.15.31'
|
VERSION = '1.2.15.1'
|
||||||
|
|
||||||
|
|
||||||
|
# 1.2.0 开始使用C++编译成.so,替换部分代码
|
||||||
|
# 1.2.1 ota使用加密包
|
||||||
|
# 1.2.2 支持wifi ota,并且设定时区,并使用单独线程保存图片
|
||||||
|
# 1.2.3 修改ADC_TRIGGER_THRESHOLD 为2300,支持上传日志到服务器
|
||||||
|
# 1.2.4 修改ADC_TRIGGER_THRESHOLD 为3000,并默认关闭摄像头的显示,并把ADC的采样间隔从50ms降低到10ms
|
||||||
|
# 1.2.5 支持空气传感器采样,并默认关闭日志。优化断网时的发送队列丢消息问题,解决 WiFi 断线检测不可靠问题。
|
||||||
|
# 1.2.6 在链接 wifi 前先判断 wifi 的可用性,假如不可用,则不落盘。增加日志批量压缩上传功能
|
||||||
|
# 1.2.7 修复OTA失败的bug, 空气压力传感器的阈值是2500
|
||||||
|
# 1.2.8 (1) 加快 wifi 下数据传输的速度。(2) 调整射箭时处理的逻辑,优先上报数据,再存照片之类的操作。(3)假如是用户打开激光的,射箭触发后不再关闭激光,因为是调瞄阶段
|
||||||
|
# 1.2.9 增加电源板的控制和自动关机的功能
|
||||||
|
# 1.2.10 config formal
|
||||||
|
# 1.2.11 增加三角形的单应性算法,适配对应的靶纸
|
||||||
|
# 1.2.110 关掉了黑色三角形算法,只用于测试
|
||||||
|
# 1.2.13 修改wifi连接
|
||||||
|
# 1.2.14 修改了icc登录部分
|
||||||
|
# 1.2.15.1 增加了标靶判断 20 40
|
||||||
|
# 1.2.16.1 增加激光校准,三角形方向判断,时间开关
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import os
|
|||||||
import math
|
import math
|
||||||
import threading
|
import threading
|
||||||
import queue
|
import queue
|
||||||
|
import time
|
||||||
from maix import image
|
from maix import image
|
||||||
import config
|
import config
|
||||||
from logger_manager import logger_manager
|
from logger_manager import logger_manager
|
||||||
@@ -531,11 +532,14 @@ def detect_circle_v3(frame, laser_point=None, img_cv=None):
|
|||||||
if img_cv is None:
|
if img_cv is None:
|
||||||
img_cv = image.image2cv(frame, False, False)
|
img_cv = image.image2cv(frame, False, False)
|
||||||
logger = logger_manager.logger
|
logger = logger_manager.logger
|
||||||
|
_timing_on = bool(getattr(config, "VISION_TIMING_ENABLE", True))
|
||||||
|
_t0 = time.perf_counter() if _timing_on else None
|
||||||
|
_t1 = _t2 = _t3 = _t4 = _t5 = None
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
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 = 480
|
MAX_DET_DIM = 320
|
||||||
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
|
||||||
@@ -554,6 +558,8 @@ def detect_circle_v3(frame, laser_point=None, img_cv=None):
|
|||||||
ellipse_params = None
|
ellipse_params = None
|
||||||
|
|
||||||
logger.debug(f"[detect_circle_v3] step 1 fin {datetime.now()}")
|
logger.debug(f"[detect_circle_v3] step 1 fin {datetime.now()}")
|
||||||
|
if _timing_on:
|
||||||
|
_t1 = time.perf_counter()
|
||||||
|
|
||||||
# -- 2. HSV + 黄色掩码
|
# -- 2. HSV + 黄色掩码
|
||||||
hsv = cv2.cvtColor(img_det, cv2.COLOR_RGB2HSV)
|
hsv = cv2.cvtColor(img_det, cv2.COLOR_RGB2HSV)
|
||||||
@@ -567,25 +573,26 @@ def detect_circle_v3(frame, laser_point=None, img_cv=None):
|
|||||||
mask_yellow = cv2.morphologyEx(mask_yellow, cv2.MORPH_CLOSE, kernel)
|
mask_yellow = cv2.morphologyEx(mask_yellow, cv2.MORPH_CLOSE, kernel)
|
||||||
|
|
||||||
logger.debug(f"[detect_circle_v3] step 2 fin {datetime.now()}")
|
logger.debug(f"[detect_circle_v3] step 2 fin {datetime.now()}")
|
||||||
|
if _timing_on:
|
||||||
|
_t2 = time.perf_counter()
|
||||||
|
_t3 = time.perf_counter()
|
||||||
|
|
||||||
# -- 3. 红色掩码:在循环外只算一次
|
# -- 3. 红色掩码:在循环外只算一次
|
||||||
mask_red = cv2.bitwise_or(
|
mask_red = cv2.bitwise_or(
|
||||||
cv2.inRange(hsv, np.array([0, 30, 20]), np.array([12, 255, 255])),
|
cv2.inRange(hsv, np.array([0, 80, 0]), np.array([10, 255, 255])),
|
||||||
cv2.inRange(hsv, np.array([168, 30, 20]), np.array([180, 255, 255])),
|
cv2.inRange(hsv, np.array([170, 80, 0]), 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 <= 10:
|
if ar <= 50:
|
||||||
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.2:
|
if pr <= 0 or (4 * np.pi * ar) / (pr * pr) <= 0.6:
|
||||||
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)
|
||||||
@@ -595,19 +602,22 @@ def detect_circle_v3(frame, laser_point=None, img_cv=None):
|
|||||||
red_candidates.append({"center": (int(xr), int(yr)), "radius": int(rr)})
|
red_candidates.append({"center": (int(xr), int(yr)), "radius": int(rr)})
|
||||||
|
|
||||||
logger.debug(f"[detect_circle_v3] step 3 fin {datetime.now()}")
|
logger.debug(f"[detect_circle_v3] step 3 fin {datetime.now()}")
|
||||||
|
if _timing_on:
|
||||||
|
_t3 = time.perf_counter()
|
||||||
|
_t4 = time.perf_counter()
|
||||||
|
|
||||||
# -- 4. 黄色轮廓循环(复用上面的红色候选列表)
|
# -- 4. 黄色轮廓循环(复用上面的红色候选列表)
|
||||||
contours_yellow, _ = cv2.findContours(mask_yellow, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE)
|
contours_yellow, _ = cv2.findContours(mask_yellow, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE)
|
||||||
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 <= 15:
|
if area <= 50:
|
||||||
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.5:
|
if circularity <= 0.7:
|
||||||
continue
|
continue
|
||||||
if logger:
|
if logger:
|
||||||
logger.info(f"[target] -> 面积:{area:.1f}, 圆度:{circularity:.2f}")
|
logger.info(f"[target] -> 面积:{area:.1f}, 圆度:{circularity:.2f}")
|
||||||
@@ -627,11 +637,7 @@ 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)
|
||||||
max_dist = yellow_radius * 2.0
|
if dist_centers < yellow_radius * 1.5 and rc["radius"] > yellow_radius * 0.8:
|
||||||
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}, "
|
||||||
@@ -644,19 +650,13 @@ def detect_circle_v3(frame, laser_point=None, img_cv=None):
|
|||||||
})
|
})
|
||||||
matched = True
|
matched = True
|
||||||
break
|
break
|
||||||
if not matched:
|
if not matched and logger:
|
||||||
# 黄圈高置信度兜底:大且圆时跳过红圈验证
|
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()}")
|
||||||
|
if _timing_on:
|
||||||
|
_t4 = time.perf_counter()
|
||||||
|
_t5 = time.perf_counter()
|
||||||
|
|
||||||
# -- 5. 选最佳目标,坐标还原到原始分辨率
|
# -- 5. 选最佳目标,坐标还原到原始分辨率
|
||||||
if valid_targets:
|
if valid_targets:
|
||||||
@@ -684,7 +684,20 @@ def detect_circle_v3(frame, laser_point=None, img_cv=None):
|
|||||||
ellipse_params = be
|
ellipse_params = be
|
||||||
best_radius1 = best_radius * 5
|
best_radius1 = best_radius * 5
|
||||||
result_img = image.cv2image(img_cv, False, False)
|
result_img = image.cv2image(img_cv, False, False)
|
||||||
logger.debug(f"[detect_circle_v3] step 5 fin {datetime.now()}")
|
if _timing_on:
|
||||||
|
_t5 = time.perf_counter()
|
||||||
|
_t_all = (_t5 - _t0) * 1000
|
||||||
|
_ms1 = (_t1 - _t0) * 1000
|
||||||
|
_ms2 = (_t2 - _t1) * 1000
|
||||||
|
_ms3 = (_t3 - _t2) * 1000
|
||||||
|
_ms4 = (_t4 - _t3) * 1000
|
||||||
|
_ms5 = (_t5 - _t4) * 1000
|
||||||
|
logger.info(
|
||||||
|
f"[VISION timing] total={_t_all:.1f}ms "
|
||||||
|
f"resize={_ms1:.1f} hsv_yellow={_ms2:.1f} "
|
||||||
|
f"red_mask={_ms3:.1f} yellow_loop={_ms4:.1f} "
|
||||||
|
f"select_cv2img={_ms5:.1f}"
|
||||||
|
)
|
||||||
return result_img, best_center, best_radius, method, best_radius1, ellipse_params
|
return result_img, best_center, best_radius, method, best_radius1, ellipse_params
|
||||||
|
|
||||||
def estimate_distance(pixel_radius):
|
def estimate_distance(pixel_radius):
|
||||||
@@ -936,6 +949,51 @@ def start_save_shot_worker():
|
|||||||
logger.info("[VISION] 存图 worker 线程已启动")
|
logger.info("[VISION] 存图 worker 线程已启动")
|
||||||
|
|
||||||
|
|
||||||
|
def enqueue_save_raw_shot(frame, shot_id=None, photo_dir=None):
|
||||||
|
"""
|
||||||
|
异步保存射箭原图(无算法标注)。需 SAVE_IMAGE_ENABLED 且 SAVE_RAW_SHOT_IMAGE_ENABLED。
|
||||||
|
文件名:{photo_dir}/shot_{shot_id}_raw.jpg
|
||||||
|
"""
|
||||||
|
if not getattr(config, "SAVE_RAW_SHOT_IMAGE_ENABLED", False):
|
||||||
|
return
|
||||||
|
if not getattr(config, "SAVE_IMAGE_ENABLED", True):
|
||||||
|
return
|
||||||
|
if not shot_id:
|
||||||
|
return
|
||||||
|
if photo_dir is None:
|
||||||
|
photo_dir = config.PHOTO_DIR
|
||||||
|
|
||||||
|
try:
|
||||||
|
img_cv = image.image2cv(frame, False, False)
|
||||||
|
img_copy = np.copy(img_cv)
|
||||||
|
except Exception as e:
|
||||||
|
logger = logger_manager.logger
|
||||||
|
if logger:
|
||||||
|
logger.error(f"[VISION] enqueue_save_raw_shot 复制图像失败: {e}")
|
||||||
|
return
|
||||||
|
|
||||||
|
def _job():
|
||||||
|
try:
|
||||||
|
try:
|
||||||
|
if photo_dir not in os.listdir("/root"):
|
||||||
|
os.mkdir(photo_dir)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
filename = f"{photo_dir}/shot_{shot_id}_raw.jpg"
|
||||||
|
out = image.cv2image(img_copy, False, False)
|
||||||
|
out.save(filename)
|
||||||
|
logger = logger_manager.logger
|
||||||
|
if logger:
|
||||||
|
logger.info(f"[VISION] 已保存射箭原图: {filename}")
|
||||||
|
prune_old_images_in_dir(photo_dir, config.MAX_IMAGES, logger, "[VISION]")
|
||||||
|
except Exception as e:
|
||||||
|
logger = logger_manager.logger
|
||||||
|
if logger:
|
||||||
|
logger.error(f"[VISION] 保存射箭原图失败: {e}")
|
||||||
|
|
||||||
|
threading.Thread(target=_job, daemon=True).start()
|
||||||
|
|
||||||
|
|
||||||
def enqueue_save_shot(result_img, center, radius, method, ellipse_params,
|
def enqueue_save_shot(result_img, center, radius, method, ellipse_params,
|
||||||
laser_point, distance_m, shot_id=None, photo_dir=None,
|
laser_point, distance_m, shot_id=None, photo_dir=None,
|
||||||
yolo_roi_xyxy=None):
|
yolo_roi_xyxy=None):
|
||||||
@@ -1025,3 +1083,63 @@ def detect_target(frame, laser_point=None):
|
|||||||
logger.debug("[VISION] 使用传统黄色靶心检测")
|
logger.debug("[VISION] 使用传统黄色靶心检测")
|
||||||
return detect_circle_v3(frame, laser_point)
|
return detect_circle_v3(frame, laser_point)
|
||||||
|
|
||||||
|
|
||||||
|
def sample_target_rgb_at_physical_radius(frame, target_center, target_radius_px, radius_cm=None, angles_deg=None, patch_half_px=None, black_thresh=None, timing=False):
|
||||||
|
"""
|
||||||
|
在物方半径位置采样 RGB,判断黑/白靶。
|
||||||
|
返回: dict {ok, is_black, mean_rgb, samples, black_ratio, elapsed_ms}
|
||||||
|
"""
|
||||||
|
logger = logger_manager.logger
|
||||||
|
if target_center is None or target_radius_px is None:
|
||||||
|
return {"ok": False, "reason": "no_target", "is_black": None, "elapsed_ms": 0.0}
|
||||||
|
|
||||||
|
radius_cm = float(radius_cm if radius_cm is not None else getattr(config, "TRIANGLE_SAMPLE_RADIUS_CM", 15.0))
|
||||||
|
angles_deg = tuple(angles_deg if angles_deg is not None else getattr(config, "TRIANGLE_SAMPLE_ANGLES_DEG", (0, 90, 180, 270)))
|
||||||
|
patch_half_px = int(patch_half_px if patch_half_px is not None else getattr(config, "TRIANGLE_SAMPLE_PATCH_HALF_PX", 2))
|
||||||
|
black_thresh = float(black_thresh if black_thresh is not None else getattr(config, "TRIANGLE_SAMPLE_BLACK_THRESH", 30.0))
|
||||||
|
timing_on = bool(timing) and bool(getattr(config, "TRIANGLE_SAMPLE_TIMING_ENABLE", True))
|
||||||
|
t0 = time.perf_counter() if timing_on else None
|
||||||
|
|
||||||
|
try:
|
||||||
|
img_cv = image.image2cv(frame, False, False)
|
||||||
|
h, w = img_cv.shape[:2]
|
||||||
|
cx, cy = float(target_center[0]), float(target_center[1])
|
||||||
|
scale = float(target_radius_px) / max(radius_cm, 1e-6)
|
||||||
|
samples = []
|
||||||
|
black_count = 0
|
||||||
|
for ang in angles_deg:
|
||||||
|
rad = math.radians(float(ang))
|
||||||
|
sx = int(round(cx + math.cos(rad) * radius_cm * scale))
|
||||||
|
sy = int(round(cy + math.sin(rad) * radius_cm * scale))
|
||||||
|
x0 = max(0, sx - patch_half_px)
|
||||||
|
y0 = max(0, sy - patch_half_px)
|
||||||
|
x1 = min(w, sx + patch_half_px + 1)
|
||||||
|
y1 = min(h, sy + patch_half_px + 1)
|
||||||
|
if x1 <= x0 or y1 <= y0:
|
||||||
|
continue
|
||||||
|
patch = img_cv[y0:y1, x0:x1]
|
||||||
|
mean_rgb = patch.reshape(-1, 3).mean(axis=0)
|
||||||
|
is_black = bool(np.all(mean_rgb < black_thresh))
|
||||||
|
black_count += 1 if is_black else 0
|
||||||
|
samples.append({"angle": float(ang), "xy": (sx, sy), "mean_rgb": tuple(float(v) for v in mean_rgb), "is_black": is_black})
|
||||||
|
black_ratio = float(black_count) / float(len(samples) or 1)
|
||||||
|
out = {
|
||||||
|
"ok": len(samples) > 0,
|
||||||
|
"is_black": black_ratio >= 0.5,
|
||||||
|
"mean_rgb": tuple(float(v) for v in (np.mean([s["mean_rgb"] for s in samples], axis=0) if samples else (0, 0, 0))),
|
||||||
|
"samples": samples,
|
||||||
|
"black_ratio": black_ratio,
|
||||||
|
"elapsed_ms": (time.perf_counter() - t0) * 1000.0 if timing_on else 0.0,
|
||||||
|
}
|
||||||
|
if logger:
|
||||||
|
logger.info(
|
||||||
|
f"[TRI-SAMPLE] radius_cm={radius_cm:.1f} black_thresh={black_thresh:.1f} "
|
||||||
|
f"black_ratio={black_ratio:.2f} is_black={out['is_black']} "
|
||||||
|
f"elapsed_ms={out['elapsed_ms']:.1f} samples={len(samples)}"
|
||||||
|
)
|
||||||
|
return out
|
||||||
|
except Exception as e:
|
||||||
|
if logger:
|
||||||
|
logger.error(f"[TRI-SAMPLE] 采样失败: {e}")
|
||||||
|
return {"ok": False, "reason": str(e), "is_black": None, "elapsed_ms": 0.0}
|
||||||
|
|
||||||
|
|||||||
@@ -41,7 +41,6 @@ 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
|
||||||
|
|
||||||
@@ -239,6 +238,7 @@ 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,13 +250,9 @@ class WiFiManager:
|
|||||||
_write_text(conf_path, full_conf)
|
_write_text(conf_path, full_conf)
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
# 删除 wpa_supplicant.conf,让 S30wifi 回退读 ssid/pass
|
_write_text(boot_wpa_path, full_conf)
|
||||||
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())
|
||||||
|
|
||||||
@@ -296,6 +292,7 @@ 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 凭证已保留(持久化)")
|
||||||
@@ -309,6 +306,7 @@ 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)
|
||||||
@@ -353,11 +351,7 @@ 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)
|
||||||
try:
|
_write_text(boot_wpa_path, full_conf)
|
||||||
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:
|
||||||
@@ -541,88 +535,73 @@ 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 秒检查 STA 关联状态和 RSSI)
|
启动 WiFi 质量后台监测线程(每 5 秒测量一次 RTT 和 RSSI)
|
||||||
只在 WiFi 连接时运行,不影响业务发送性能
|
只在 WiFi 连接时运行,不影响业务发送性能
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
network_type_callback: 获取当前网络类型的回调函数
|
network_type_callback: 获取当前网络类型的回调函数
|
||||||
on_poor_quality_callback: WiFi质量差时的回调函数
|
on_poor_quality_callback: WiFi质量差时的回调函数
|
||||||
"""
|
"""
|
||||||
with self._wifi_quality_lock:
|
if self._wifi_quality_monitor_thread is not None:
|
||||||
if self._wifi_quality_monitor_thread is not None and self._wifi_quality_monitor_thread.is_alive():
|
self.logger.warning("[WiFi Monitor] 监测线程已在运行")
|
||||||
self.logger.warning("[WiFi Monitor] 监测线程已在运行")
|
return
|
||||||
return
|
|
||||||
|
|
||||||
self._network_type_callback = network_type_callback
|
self._network_type_callback = network_type_callback
|
||||||
self._on_poor_quality_callback = on_poor_quality_callback
|
self._on_poor_quality_callback = on_poor_quality_callback
|
||||||
self._wifi_quality_stop_event.clear()
|
self._wifi_quality_stop_event.clear()
|
||||||
self._wifi_quality_monitor_thread = threading.Thread(
|
self._wifi_quality_monitor_thread = threading.Thread(
|
||||||
target=self._quality_monitor_loop,
|
target=self._quality_monitor_loop,
|
||||||
daemon=True,
|
daemon=True,
|
||||||
name="wifi_quality_monitor"
|
name="wifi_quality_monitor"
|
||||||
)
|
)
|
||||||
self._wifi_quality_monitor_thread.start()
|
self._wifi_quality_monitor_thread.start()
|
||||||
self.logger.info("[WiFi Monitor] 已启动后台监测线程")
|
self.logger.info("[WiFi Monitor] 已启动后台监测线程")
|
||||||
|
|
||||||
def stop_quality_monitor(self):
|
def stop_quality_monitor(self):
|
||||||
"""停止 WiFi 质量监测线程"""
|
"""停止 WiFi 质量监测线程"""
|
||||||
with self._wifi_quality_lock:
|
if self._wifi_quality_monitor_thread is None:
|
||||||
t = self._wifi_quality_monitor_thread
|
return
|
||||||
if t is None:
|
|
||||||
return
|
|
||||||
if not t.is_alive():
|
|
||||||
self._wifi_quality_monitor_thread = None
|
|
||||||
return
|
|
||||||
|
|
||||||
self._wifi_quality_stop_event.set()
|
self._wifi_quality_stop_event.set()
|
||||||
try:
|
try:
|
||||||
t.join(timeout=2.0)
|
self._wifi_quality_monitor_thread.join(timeout=2.0)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
self.logger.error(f"[WiFi Monitor] 停止线程失败:{e}")
|
self.logger.error(f"[WiFi Monitor] 停止线程失败:{e}")
|
||||||
|
finally:
|
||||||
with self._wifi_quality_lock:
|
self._wifi_quality_monitor_thread = None
|
||||||
if t is self._wifi_quality_monitor_thread:
|
self.logger.info("[WiFi Monitor] 已停止后台监测线程")
|
||||||
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):
|
||||||
"""
|
"""
|
||||||
WiFi 质量监测循环(后台线程)
|
WiFi 质量监测循环(后台线程)
|
||||||
每 5 秒检查 STA 关联状态和 RSSI,发现断链或质量差则触发切换
|
每 5 秒测量一次 RTT 和 RSSI,发现质量差则触发切换
|
||||||
"""
|
"""
|
||||||
while not self._wifi_quality_stop_event.is_set():
|
while not self._wifi_quality_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 测量当前禁用;STA 关联状态用于判断物理 WiFi 链路是否仍存在。
|
# # 测量 RTT(1 个样本,快速测量)
|
||||||
# 不能把禁用的 RTT 伪装成 0ms,否则关闭热点后会一直被判为正常。
|
# rtt_ms, reachable = self._measure_wifi_tcp_rtt_ms(
|
||||||
reachable = self.is_sta_associated()
|
# self._server_ip, self._server_port,
|
||||||
rtt_ms = None
|
# samples=1, per_sample_timeout_ms=600
|
||||||
|
# )
|
||||||
|
|
||||||
# 获取 RSSI
|
# 获取 RSSI
|
||||||
rssi_dbm = self._get_wifi_rssi_dbm()
|
rssi_dbm = self._get_wifi_rssi_dbm()
|
||||||
|
|
||||||
# 更新缓存
|
# 更新缓存
|
||||||
self._last_wifi_rtt_ms = rtt_ms
|
# 不使用 RTT 测量
|
||||||
|
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(
|
self.logger.debug(f"[WiFi Monitor] - RTT={rtt_ms:.0f}ms, RSSI={_rssi_s}dBm")
|
||||||
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:
|
if (not _reachable) or (_rtt is None) or (_rtt == float("inf")):
|
||||||
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)
|
||||||
|
|
||||||
@@ -632,8 +611,13 @@ class WiFiManager:
|
|||||||
|
|
||||||
for retry_idx in range(2):
|
for retry_idx in range(2):
|
||||||
time.sleep_ms(1000)
|
time.sleep_ms(1000)
|
||||||
reachable2 = self.is_sta_associated()
|
# 不使用 RTT 测量
|
||||||
rtt2 = None
|
rtt2 = 0
|
||||||
|
reachable2 = True
|
||||||
|
# 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()
|
||||||
|
|
||||||
# 更新缓存,便于外部查看最新状态
|
# 更新缓存,便于外部查看最新状态
|
||||||
@@ -642,10 +626,14 @@ class WiFiManager:
|
|||||||
|
|
||||||
bad2 = _is_bad_now(reachable2, rtt2, rssi2)
|
bad2 = _is_bad_now(reachable2, rtt2, rssi2)
|
||||||
try:
|
try:
|
||||||
_rtt_disp = f"{rtt2:.0f}ms" if rtt2 is not None else "n/a"
|
_rtt_disp = (
|
||||||
|
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}, rssi={rssi2}, bad={bad2}"
|
f"rtt={_rtt_disp:.0f}ms, rssi={rssi2}, bad={bad2}"
|
||||||
)
|
)
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
|
|||||||
+267
@@ -0,0 +1,267 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
"""Standalone live camera + single YOLO runner.
|
||||||
|
|
||||||
|
不复用项目内的 `camera_manager` / `target_roi_yolo` / `config` / `logger_manager`。
|
||||||
|
|
||||||
|
功能:
|
||||||
|
- 独立初始化摄像头
|
||||||
|
- 实时读取帧
|
||||||
|
- 独立加载单个 YOLO 模型并推理
|
||||||
|
- 画出检测框、ROI、FPS
|
||||||
|
|
||||||
|
适用场景:
|
||||||
|
- 单独验证一个模型是否能跑
|
||||||
|
- 验证实时帧率
|
||||||
|
- 验证 ROI 是否裁对
|
||||||
|
- 不进入主业务射箭流程
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
|
import time
|
||||||
|
from dataclasses import dataclass
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class RunnerConfig:
|
||||||
|
camera_width: int = 640
|
||||||
|
camera_height: int = 480
|
||||||
|
model_path: str = "/root/model_278702.mud"
|
||||||
|
conf_th: float = 0.7
|
||||||
|
retry_conf_th: float = 0.5
|
||||||
|
class_ids: tuple = (0,)
|
||||||
|
merge_mode: str = "union"
|
||||||
|
coord_mode: str = "native"
|
||||||
|
roi_margin_frac: float = 0.11
|
||||||
|
min_box_side_px: int = 8
|
||||||
|
|
||||||
|
|
||||||
|
def log(msg: str):
|
||||||
|
print(msg)
|
||||||
|
|
||||||
|
|
||||||
|
class DummyLogger:
|
||||||
|
def info(self, msg):
|
||||||
|
log(msg)
|
||||||
|
|
||||||
|
def warning(self, msg):
|
||||||
|
log(msg)
|
||||||
|
|
||||||
|
def error(self, msg):
|
||||||
|
log(msg)
|
||||||
|
|
||||||
|
|
||||||
|
class StandaloneYOLORunner:
|
||||||
|
def __init__(self, cfg: RunnerConfig):
|
||||||
|
self.cfg = cfg
|
||||||
|
self.logger = DummyLogger()
|
||||||
|
self._last_fps_t = time.perf_counter()
|
||||||
|
self._frames = 0
|
||||||
|
self._fps = 0.0
|
||||||
|
self._camera = None
|
||||||
|
self._det = None
|
||||||
|
|
||||||
|
def _import_maix(self):
|
||||||
|
try:
|
||||||
|
from maix import camera, image, nn
|
||||||
|
return camera, image, nn
|
||||||
|
except Exception as e:
|
||||||
|
raise RuntimeError(f"maix import failed: {e}")
|
||||||
|
|
||||||
|
def _init_camera(self):
|
||||||
|
camera, _, _ = self._import_maix()
|
||||||
|
if self._camera is not None:
|
||||||
|
return self._camera
|
||||||
|
try:
|
||||||
|
self._camera = camera.Camera(
|
||||||
|
width=self.cfg.camera_width,
|
||||||
|
height=self.cfg.camera_height,
|
||||||
|
format=camera.RGB888,
|
||||||
|
)
|
||||||
|
except Exception:
|
||||||
|
self._camera = camera.Camera(width=self.cfg.camera_width, height=self.cfg.camera_height)
|
||||||
|
return self._camera
|
||||||
|
|
||||||
|
def _load_detector(self, model_path: str):
|
||||||
|
_, _, nn = self._import_maix()
|
||||||
|
if not model_path or not os.path.isfile(model_path):
|
||||||
|
return None
|
||||||
|
return nn.YOLOv5(model=model_path, dual_buff=False)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _get_class_id(obj):
|
||||||
|
for key in ("class_id", "cls", "label", "category", "cat_id", "id"):
|
||||||
|
if hasattr(obj, key):
|
||||||
|
v = getattr(obj, key)
|
||||||
|
if v is None:
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
return int(float(v))
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
return None
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _normalize_boxes(raw):
|
||||||
|
out = []
|
||||||
|
for o in raw or []:
|
||||||
|
if isinstance(o, (list, tuple)) and len(o) >= 6:
|
||||||
|
class Box:
|
||||||
|
pass
|
||||||
|
b = Box()
|
||||||
|
b.x, b.y, b.w, b.h, b.score, b.class_id = map(float, o[:6])
|
||||||
|
out.append(b)
|
||||||
|
else:
|
||||||
|
out.append(o)
|
||||||
|
return out
|
||||||
|
|
||||||
|
def _det_to_xyxy(self, det, obj):
|
||||||
|
x = float(getattr(obj, "x", 0.0))
|
||||||
|
y = float(getattr(obj, "y", 0.0))
|
||||||
|
w = float(getattr(obj, "w", 0.0))
|
||||||
|
h = float(getattr(obj, "h", 0.0))
|
||||||
|
return x, y, x + w, y + h
|
||||||
|
|
||||||
|
def _run_detector(self, det, img, conf_th, class_ids):
|
||||||
|
if det is None:
|
||||||
|
return []
|
||||||
|
raw = det.detect(img, conf_th=conf_th)
|
||||||
|
objs = self._normalize_boxes(raw if raw is not None else [])
|
||||||
|
out = []
|
||||||
|
for o in objs:
|
||||||
|
cid = self._get_class_id(o)
|
||||||
|
if cid is not None and cid not in class_ids:
|
||||||
|
continue
|
||||||
|
out.append(o)
|
||||||
|
return out
|
||||||
|
|
||||||
|
def _calc_fps(self):
|
||||||
|
self._frames += 1
|
||||||
|
now = time.perf_counter()
|
||||||
|
dt = now - self._last_fps_t
|
||||||
|
if dt >= 1.0:
|
||||||
|
self._fps = self._frames / dt
|
||||||
|
self._frames = 0
|
||||||
|
self._last_fps_t = now
|
||||||
|
return self._fps
|
||||||
|
|
||||||
|
def _draw_text(self, img, lines):
|
||||||
|
try:
|
||||||
|
import cv2
|
||||||
|
y = 24
|
||||||
|
for line in lines:
|
||||||
|
cv2.putText(img, line, (10, y), cv2.FONT_HERSHEY_SIMPLEX, 0.55, (0, 255, 0), 1, cv2.LINE_AA)
|
||||||
|
y += 20
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
def _clip_roi(self, x0, y0, x1, y1, w, h):
|
||||||
|
x0 = max(0, min(int(x0), w - 1))
|
||||||
|
y0 = max(0, min(int(y0), h - 1))
|
||||||
|
x1 = max(x0 + 1, min(int(x1), w))
|
||||||
|
y1 = max(y0 + 1, min(int(y1), h))
|
||||||
|
return x0, y0, x1, y1
|
||||||
|
|
||||||
|
def _merge_boxes(self, boxes):
|
||||||
|
if not boxes:
|
||||||
|
return None
|
||||||
|
x0 = min(b[0] for b in boxes)
|
||||||
|
y0 = min(b[1] for b in boxes)
|
||||||
|
x1 = max(b[2] for b in boxes)
|
||||||
|
y1 = max(b[3] for b in boxes)
|
||||||
|
return x0, y0, x1, y1
|
||||||
|
|
||||||
|
def _run_single_yolo(self, frame, img_cv):
|
||||||
|
h, w = int(img_cv.shape[0]), int(img_cv.shape[1])
|
||||||
|
if self._det is None:
|
||||||
|
self._det = self._load_detector(self.cfg.model_path)
|
||||||
|
det = self._det
|
||||||
|
if det is None:
|
||||||
|
return []
|
||||||
|
|
||||||
|
boxes = self._run_detector(det, frame, self.cfg.conf_th, self.cfg.class_ids)
|
||||||
|
if not boxes and self.cfg.retry_conf_th < self.cfg.conf_th:
|
||||||
|
boxes = self._run_detector(det, frame, self.cfg.retry_conf_th, self.cfg.class_ids)
|
||||||
|
|
||||||
|
xyxy = []
|
||||||
|
for obj in boxes:
|
||||||
|
x0, y0, x1, y1 = self._det_to_xyxy(det, obj)
|
||||||
|
if (x1 - x0) < self.cfg.min_box_side_px or (y1 - y0) < self.cfg.min_box_side_px:
|
||||||
|
continue
|
||||||
|
if self.cfg.coord_mode == "native":
|
||||||
|
x0, y0, x1, y1 = self._clip_roi(x0, y0, x1, y1, w, h)
|
||||||
|
xyxy.append((x0, y0, x1, y1))
|
||||||
|
return xyxy
|
||||||
|
|
||||||
|
def run(self):
|
||||||
|
_, image, _ = self._import_maix()
|
||||||
|
cam = self._init_camera()
|
||||||
|
log("[YOLOTE] standalone runner started")
|
||||||
|
|
||||||
|
while True:
|
||||||
|
try:
|
||||||
|
frame = cam.read()
|
||||||
|
except Exception as e:
|
||||||
|
log(f"[YOLOTE] camera read failed: {e}")
|
||||||
|
time.sleep(0.02)
|
||||||
|
continue
|
||||||
|
|
||||||
|
if frame is None:
|
||||||
|
time.sleep(0.01)
|
||||||
|
continue
|
||||||
|
|
||||||
|
try:
|
||||||
|
img_cv = image.image2cv(frame, False, False)
|
||||||
|
except Exception as e:
|
||||||
|
log(f"[YOLOTE] image2cv failed: {e}")
|
||||||
|
time.sleep(0.01)
|
||||||
|
continue
|
||||||
|
|
||||||
|
import cv2
|
||||||
|
t0 = time.perf_counter()
|
||||||
|
boxes = self._run_single_yolo(frame, img_cv)
|
||||||
|
t1 = time.perf_counter()
|
||||||
|
|
||||||
|
for i, (bx0, by0, bx1, by1) in enumerate(boxes):
|
||||||
|
cv2.rectangle(img_cv, (int(bx0), int(by0)), (int(bx1) - 1, int(by1) - 1), (0, 255, 0), 2)
|
||||||
|
cv2.putText(img_cv, f"B{i}", (int(bx0), max(0, int(by0) - 4)), cv2.FONT_HERSHEY_SIMPLEX, 0.5, (0, 255, 0), 1, cv2.LINE_AA)
|
||||||
|
|
||||||
|
fps = self._calc_fps()
|
||||||
|
self._draw_text(
|
||||||
|
img_cv,
|
||||||
|
[
|
||||||
|
f"FPS: {fps:.1f}",
|
||||||
|
f"YOLO: {(t1 - t0)*1000.0:.1f} ms",
|
||||||
|
f"Boxes: {len(boxes)}",
|
||||||
|
"Ctrl+C to exit",
|
||||||
|
],
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
frame_out = image.cv2image(img_cv, False, False)
|
||||||
|
if hasattr(cam, "show"):
|
||||||
|
cam.show(frame_out)
|
||||||
|
else:
|
||||||
|
try:
|
||||||
|
frame_out.show()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
except Exception as e:
|
||||||
|
log(f"[YOLOTE] show failed: {e}")
|
||||||
|
|
||||||
|
time.sleep(0.001)
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
cfg = RunnerConfig()
|
||||||
|
runner = StandaloneYOLORunner(cfg)
|
||||||
|
try:
|
||||||
|
runner.run()
|
||||||
|
except KeyboardInterrupt:
|
||||||
|
log("[YOLOTE] interrupted")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
Reference in New Issue
Block a user