Compare commits

..

10 Commits

Author SHA1 Message Date
gcw_4spBpAfv
fe3e26e21d triangle algo refind 2026-04-24 18:38:03 +08:00
gcw_4spBpAfv
8efe1ae5c5 upload log file to qiqiu 2026-04-23 17:53:21 +08:00
gcw_4spBpAfv
12fac4ea1c remove unseed code 2026-04-23 11:14:08 +08:00
gcw_4spBpAfv
1bace88f37 refine the triangle algo 2026-04-21 21:14:12 +08:00
gcw_4spBpAfv
ba5ca7e0b3 upload img to qiniu 2026-04-20 19:03:20 +08:00
gcw_4spBpAfv
e030f3a194 triangle algo 2026-04-18 09:33:37 +08:00
gcw_4spBpAfv
43e7e0ba17 new shoot algo 2026-04-17 18:31:44 +08:00
gcw_4spBpAfv
0ee970d8bd wifi support tsl 2026-04-14 09:02:41 +08:00
gcw_4spBpAfv
ead2060ab3 wifi config while no 4g and wifi 2026-04-07 17:29:24 +08:00
gcw_4spBpAfv
bdc3254ed2 fix wifi 2 pkg issue 2026-04-03 15:40:07 +08:00
21 changed files with 3217 additions and 778 deletions

View File

@@ -1,14 +1,17 @@
id: t11 id: t11
name: t11 name: t11
version: 1.2.10 version: 1.2.11
author: t11 author: t11
icon: '' icon: ''
desc: t11 desc: t11
files: files:
- 4g_download_manager.py
- app.yaml - app.yaml
- archery_netcore.cpython-311-riscv64-linux-gnu.so - archery_netcore.cpython-311-riscv64-linux-gnu.so
- aruco_detector.py
- at_client.py - at_client.py
- camera_manager.py - camera_manager.py
- cameraParameters.xml
- config.py - config.py
- hardware.py - hardware.py
- laser_manager.py - laser_manager.py
@@ -17,9 +20,13 @@ files:
- network.py - network.py
- ota_manager.py - ota_manager.py
- power.py - power.py
- server.pem
- shoot_manager.py - shoot_manager.py
- shot_id_generator.py - shot_id_generator.py
- time_sync.py - time_sync.py
- triangle_positions.json
- triangle_target.py
- version.py - version.py
- vision.cpython-311-riscv64-linux-gnu.so - vision.py
- wifi_config_httpd.py
- wifi.py - wifi.py

Binary file not shown.

33
cameraParameters.xml Normal file
View File

@@ -0,0 +1,33 @@
<?xml version="1.0"?>
<opencv_storage>
<calibrationDate>"Sat Apr 11 12:05:27 2026"</calibrationDate>
<framesCount>29</framesCount>
<cameraResolution>
640 480</cameraResolution>
<camera_matrix type_id="opencv-matrix">
<rows>3</rows>
<cols>3</cols>
<dt>d</dt>
<data>
2207.9058323074869 0. 328.90661220953149 0. 2207.9058323074869
205.49515894111076 0. 0. 1.</data></camera_matrix>
<camera_matrix_std_dev type_id="opencv-matrix">
<rows>4</rows>
<cols>1</cols>
<dt>d</dt>
<data>
0. 11.687428265309892 3.6908895632668468 3.597571733110271</data></camera_matrix_std_dev>
<distortion_coefficients type_id="opencv-matrix">
<rows>1</rows>
<cols>5</cols>
<dt>d</dt>
<data>
-0.63036604771649651 3.3832710000807449 0. 0. -0.45113389267675552</data></distortion_coefficients>
<distortion_coefficients_std_dev type_id="opencv-matrix">
<rows>5</rows>
<cols>1</cols>
<dt>d</dt>
<data>
0.025002349846111244 1.0651877135605927 0. 0. 0.04021252864120229</data></distortion_coefficients_std_dev>
<avg_reprojection_error>0.28992233810828955</avg_reprojection_error>
</opencv_storage>

View File

@@ -9,7 +9,16 @@ from version import VERSION
# ==================== 应用配置 ==================== # ==================== 应用配置 ====================
APP_VERSION = VERSION APP_VERSION = VERSION
APP_DIR = "/maixapp/apps/t11" APP_DIR = "/maixapp/apps/t11"
LOCAL_FILENAME = "/maixapp/apps/t11/main_tmp.py" LOCAL_FILENAME = APP_DIR + "/main_tmp.py"
# ==================== 相机配置 ====================
# 相机初始化分辨率CameraManager / main.py 使用)
CAMERA_WIDTH = 640
CAMERA_HEIGHT = 480
# 三角形检测缩图比例:默认按相机最长边缩到 1/2性能更稳可按需调整
# 取值范围建议 (0.25 ~ 1.0]1.0 表示不缩图
TRIANGLE_DETECT_SCALE = 0.5
# ==================== 服务器配置 ==================== # ==================== 服务器配置 ====================
# SERVER_IP = "stcp.shelingxingqiu.com" # SERVER_IP = "stcp.shelingxingqiu.com"
@@ -22,27 +31,38 @@ WIFI_QUALITY_RTT_SAMPLES = 3 # 到业务服务器 TCP 建连耗时采样次数
WIFI_QUALITY_RTT_BAD_MS = 600.0 # 中位数超过此值认为延迟过高 WIFI_QUALITY_RTT_BAD_MS = 600.0 # 中位数超过此值认为延迟过高
WIFI_QUALITY_RTT_WARN_MS = 350.0 # 与 RSSI 联合:超过此值且信号弱也判为差 WIFI_QUALITY_RTT_WARN_MS = 350.0 # 与 RSSI 联合:超过此值且信号弱也判为差
WIFI_QUALITY_RSSI_BAD_DBM = -80.0 # 低于此 dBm更负更差视为信号弱 WIFI_QUALITY_RSSI_BAD_DBM = -80.0 # 低于此 dBm更负更差视为信号弱
WIFI_QUALITY_USE_RSSI = True # 是否把 RSSI 纳入综合判定False 则仅看 RTT WIFI_QUALITY_USE_RSSI = True # 是否把 RSSI 纳入综合判定
# WiFi 热点配网(手机连设备 AP浏览器提交路由器 SSID/密码;仅 GET/POST标准库 socket
WIFI_CONFIG_AP_FALLBACK = True # # WiFi 配网失败时,是否退回热点模式,并等待重新配网
WIFI_AP_FALLBACK_WAIT_SEC = 5 # 等待5秒后再检测STA/4G
WIFI_CONFIG_AP_TIMEOUT = 5 # 热点模式超时时间(秒)
WIFI_CONFIG_AP_ENABLED = True # True=启动时开热点并起迷你 HTTP 配网服务
WIFI_CONFIG_AP_SSID = "ArcherySetup" # 设备发出的热点名称
WIFI_CONFIG_AP_PASSWORD = "12345678" # 热点密码WPA2 通常至少 8 位)
WIFI_CONFIG_HTTP_HOST = "0.0.0.0" # HTTP 监听地址
WIFI_CONFIG_HTTP_PORT = 8080 # 默认 8080避免占用 80 需 root
WIFI_CONFIG_AP_IP = "192.168.66.1" # 与 MaixPy Wifi.start_ap 默认一致,手机访问 http://192.168.66.1:8080/
# ===== TCP over SSL(TLS) 配置 ===== # ===== TCP over SSL(TLS) 配置 =====
USE_TCP_SSL = False # True=按手册走 MSSLCFG/MIPCFG 绑定 SSL USE_TCP_SSL = True # True=按手册走 MSSLCFG/MIPCFG 绑定 SSL
TCP_LINK_ID = 2 # TCP_LINK_ID = 2 #
TCP_SSL_PORT = 443 # TLS 端口(不一定必须 443以服务器为准 TCP_SSL_PORT = 50006 # TLS 端口(不一定必须 443以服务器为准
# SSL profile # SSL profile
SSL_ID = 1 # ssl_id=1 SSL_ID = 1 # ssl_id=1
SSL_AUTH_MODE = 0 # 1=单向认证验证服务器2=双向 SSL_AUTH_MODE = 1 # 1=单向认证验证服务器2=双向
SSL_VERIFY_MODE = 1 # 0=不验仅测试用1=写入并使用 CA 证书 SSL_VERIFY_MODE = 1 # 0=不验仅测试用1=写入并使用 CA 证书
SSL_CERT_FILENAME = "www.shelingxingqiu.com.crt" # 模组里证书名MSSLCERTWR / MSSLCFG="cert" 用) SSL_CERT_FILENAME = "server.pem" # 模组里证书名MSSLCERTWR / MSSLCFG="cert" 用)
SSL_CERT_PATH = "/root/www.shelingxingqiu.com.crt" # 设备文件系统里 CA 证书路径(你自己放进去) SSL_CERT_PATH = APP_DIR + "/server.pem" # 设备文件系统里 CA 证书路径(你自己放进去)
# MIPOPEN 末尾的参数在不同固件里含义可能不同;按你手册例子保留 # MIPOPEN 末尾的参数在不同固件里含义可能不同;按你手册例子保留
MIPOPEN_TAIL = ",,0" MIPOPEN_TAIL = ",,0"
# ==================== 文件路径配置 ==================== # ==================== 文件路径配置 ====================
CONFIG_FILE = "/root/laser_config.json" CONFIG_FILE = "/root/laser_config.json"
LOG_FILE = "/maixapp/apps/t11/app.log" LOG_FILE = APP_DIR + "/app.log"
BACKUP_BASE = "/maixapp/apps/t11/backups" BACKUP_BASE = APP_DIR + "/backups"
# ==================== 硬件配置 ==================== # ==================== 硬件配置 ====================
# WiFi模块开关True=有WiFi模块False=无WiFi模块 # WiFi模块开关True=有WiFi模块False=无WiFi模块
@@ -84,7 +104,7 @@ DEFAULT_LASER_POINT = (320, 245) # 默认激光中心点
# 硬编码激光点配置 # 硬编码激光点配置
HARDCODE_LASER_POINT = True # 是否使用硬编码的激光点True=使用硬编码值False=使用校准值) HARDCODE_LASER_POINT = True # 是否使用硬编码的激光点True=使用硬编码值False=使用校准值)
HARDCODE_LASER_POINT_VALUE = (320, 245) # 硬编码的激光点坐标(315, 245) # # 硬编码的激光点坐标 (x, y) HARDCODE_LASER_POINT_VALUE = (320, 296) # 硬编码的激光点坐标(315, 245) # # 硬编码的激光点坐标 (x, y)
# 激光点检测配置 # 激光点检测配置
LASER_DETECTION_THRESHOLD = 140 # 红色通道阈值默认120可调整范围建议100-150 LASER_DETECTION_THRESHOLD = 140 # 红色通道阈值默认120可调整范围建议100-150
@@ -111,6 +131,40 @@ LASER_CAMERA_OFFSET_CM = 1.4 # 激光在摄像头下方的物理距离(厘米
IMAGE_CENTER_X = 320 # 图像中心 X 坐标 IMAGE_CENTER_X = 320 # 图像中心 X 坐标
IMAGE_CENTER_Y = 240 # 图像中心 Y 坐标 IMAGE_CENTER_Y = 240 # 图像中心 Y 坐标
# ==================== 三角形四角标记:单应性偏移 + PnP 估距 ====================
# 依赖 cameraParameters.xml相机内参与 triangle_positions.json四角物方坐标厘米或毫米见 JSON 约定)。
# 部署时请把这两个文件放到 APP_DIR与 main 同应用目录),或改下面路径为设备上的实际绝对路径。
USE_TRIANGLE_OFFSET = True # False 时仅走黄心圆/椭圆 + 半径估距,不使用三角形路径
CAMERA_CALIB_XML = APP_DIR + "/cameraParameters.xml"
TRIANGLE_POSITIONS_JSON = APP_DIR + "/triangle_positions.json"
# 检测到的三角形边长在图像中的像素范围,分辨率或靶纸占比变化时可微调
TRIANGLE_SIZE_RANGE = (8, 500)
# 三角形检测兜底增强CLAHE更鲁棒但更慢。颜色阈值修复后通常不需要保持关闭以优先速度。
TRIANGLE_ENABLE_CLAHE_FALLBACK = False
# 三角形检测调试:保存 Otsu 二值化图像(临时调试用,定位后关闭)
TRIANGLE_SAVE_DEBUG_IMAGE = False
# 三角形颜色过滤阈值(三角形内部灰度判定)
# 如果三角形标记印刷较浅/环境较亮,可放宽:
# max_interior_gray: 三角形内部平均灰度上限越大越宽松90→130 适应浅色印刷)
# dark_pixel_gray: "暗像素"灰度判定阈值越大越宽松80→130
# min_dark_ratio: 暗像素占比下限越小越宽松0.70→0.30
TRIANGLE_MAX_INTERIOR_GRAY = 130
TRIANGLE_DARK_PIXEL_GRAY = 130
TRIANGLE_MIN_DARK_RATIO = 0.30
# 三角形相对对比度阈值内部比周围暗多少灰度值才认为有效0=禁用相对对比度)
TRIANGLE_MIN_CONTRAST_DIFF = 15
# 三角形检测超时(毫秒)。超过该时间直接判失败,回退圆心算法(并行时不再等待)。
# CLAHE 启用或颜色阈值放宽后检测耗时增加需相应提高1000→2500
TRIANGLE_TIMEOUT_MS = 2500
# 三角形检测性能/鲁棒性参数(偏向速度的默认值)
# 说明:
# - Otsu 是最快的全局阈值adaptiveThreshold 更鲁棒但更慢
# - filtered 候选过多时,枚举 C(n,4) 会变慢,需限幅
TRIANGLE_EARLY_EXIT_CANDIDATES = 4 # 找到多少个候选就提前停止二值化尝试
TRIANGLE_ADAPTIVE_BLOCK_SIZES = (11, 21) # 自适应阈值 blockSize 尝试列表;置空 () 可完全关闭 adaptiveThreshold
TRIANGLE_MAX_FILTERED_FOR_COMBO = 10 # 参与四点组合评分的最大候选数(超过则截断到最可能的一部分)
FLASH_LASER_WHILE_SHOOTING = True # 是否在拍摄时闪一下激光True=闪False=不闪) FLASH_LASER_WHILE_SHOOTING = True # 是否在拍摄时闪一下激光True=闪False=不闪)
FLASH_LASER_DURATION_MS = 1000 # 闪一下激光的持续时间(毫秒) FLASH_LASER_DURATION_MS = 1000 # 闪一下激光的持续时间(毫秒)
@@ -124,7 +178,7 @@ SAVE_IMAGE_ENABLED = True # 是否保存图像True=保存False=不保存
PHOTO_DIR = "/root/phot" # 照片存储目录 PHOTO_DIR = "/root/phot" # 照片存储目录
MAX_IMAGES = 1000 MAX_IMAGES = 1000
SHOW_CAMERA_PHOTO_WHILE_SHOOTING = True # 是否在拍摄时显示摄像头图像True=显示False=不显示建议在连着USB测试过程中打开 SHOW_CAMERA_PHOTO_WHILE_SHOOTING = False # 是否在拍摄时显示摄像头图像True=显示False=不显示建议在连着USB测试过程中打开
# ==================== OTA配置 ==================== # ==================== OTA配置 ====================
MAX_BACKUPS = 5 MAX_BACKUPS = 5

View File

@@ -25,6 +25,7 @@ add_library(archery_netcore MODULE
utils.cpp utils.cpp
decrypt_ota_file.cpp decrypt_ota_file.cpp
msg_handler.cpp msg_handler.cpp
tcp_ssl_password.cpp
) )
target_include_directories(archery_netcore PRIVATE target_include_directories(archery_netcore PRIVATE

View File

@@ -12,6 +12,7 @@
#include "native_logger.hpp" #include "native_logger.hpp"
#include "decrypt_ota_file.hpp" #include "decrypt_ota_file.hpp"
#include "utils.hpp" #include "utils.hpp"
#include "tcp_ssl_password.hpp"
namespace py = pybind11; namespace py = pybind11;
using json = nlohmann::json; using json = nlohmann::json;
@@ -61,6 +62,14 @@ PYBIND11_MODULE(archery_netcore, m) {
m.def("get_config", &get_config, "Get system configuration"); m.def("get_config", &get_config, "Get system configuration");
m.def(
"calculate_tcp_ssl_password",
&netcore::calculate_tcp_ssl_password,
"Calculate TCP SSL password: hex(md5(hex(md5(device_id)) + iccid))",
py::arg("device_id"),
py::arg("iccid")
);
m.def( m.def(
"decrypt_ota_file", "decrypt_ota_file",
[](const std::string& input_path, const std::string& output_zip_path) { [](const std::string& input_path, const std::string& output_zip_path) {

View File

@@ -33,3 +33,54 @@ cat /dev/ttyS2
# 3. 发送下载命令(原窗口) # 3. 发送下载命令(原窗口)
printf 'AT+MHTTPDLFILE="http://static.shelingxingqiu.com/shoot/v1/main.py","downloaded.py",5120\r\n' > /dev/ttyS2 printf 'AT+MHTTPDLFILE="http://static.shelingxingqiu.com/shoot/v1/main.py","downloaded.py",5120\r\n' > /dev/ttyS2
4. wifi的启动条件在 /boot 目录下,看看是否有 wifi.sta 和 wifi.ssid wifi.pass 这些文件。其中 wifi.sta 是开关文件。
如果没有了它就不会启动wifi流程。具体的wifi流程 由 /etc/init.d/S30wifi 控制。它会判断 wifi.sta 是否存在然后是否启动wifi还是启动热点。
5. 给自己的程序打包到基础镜像中参考https://wiki.sipeed.com/maixpy/doc/zh/pro/compile_os.html
5.1. 按照链接中的步骤去github上获取了基础镜像这次使用的是 v4.12.4把Assets中的下面几样东西下载下来我是在windows的wsl中执行的注意
假如是在windows中下载的文件在wsl中编译会很慢所以我采用的是直接在wsl中下载放到wsl的自己的文件系统中。
1maixcam-2025-12-31-maixpy-v4.12.4.img.xz
2maixcam_builtin_files.tar.xz
3MaixPy-4.12.4-py3-none-any.whl
4Source code(zip)
5.2. 把自己的文件放到 buildtin_files中
1我把项目文件目录 t11 放到了 maixcam_builtin_files\maixapp\apps 这个目录下。
2为了能让它自启动我把 auto_start.txt 放到了 maixcam_builtin_files\maixapp 这个目录下。
5.3. 然后在解压后的源码中找到tools/os目录下 /home/saga/maixcam/MaixPy-4.12.4/tools/os/maixcam
执行
export MAIXCDK_PATH=/home/saga/maixcam/MaixCDK
编译:
./gen_os.sh ../../../../../maixcam/maixcam-2025-12-31-maixpy-v4.12.4.img ../../../../../maixcam/MaixPy-4.12.4-py3-none-any.whl ../../../../../maixcam/maixcam_builtin_files 0 maixcam
注意,在编译过程中,也会去 github 下载内容,所以需要打开梯子。
5.4. 等待编译完成,会编译成镜像文件,然后根据 https://wiki.sipeed.com/hardware/zh/maixcam/os.html 这个指引来烧录系统。
5.5. 烧录完系统后,需要安装 runtime 可以按照 https://wiki.sipeed.com/maixpy/doc/zh/README_no_screen.html 这个来升级运行库,或者直接在 Maixvision 中链接的时候安装 runtime。
5.6. 安装 runtime 之后,重启,我们的系统就会自己启动起来了。
遇到问题:
/mnt/d/code/shooting/compile_maixcam/MaixPy-4.12.4/MaixPy-4.12.4/tools/os/maixcam/fuse2fs: error while loading shared libraries: libfuse.so.2: cannot open shared object file: No such file or directory
解决办法:
安装 libfuse2
sudo apt update
sudo apt install libfuse2
遇到问题:
python 缺少 yaml
解决办法:
pip install pyyaml
遇到问题:
./build_all.sh: line 56: maixtool: command not found
解决办法:
pip install maixtool
遇到问题:
./update_img.sh: line 80: mcopy: command not found
解决办法:
sudo apt update
sudo apt install mtools
6. 相机标定:
然后在板子上跑 test 目录下的 test_camera_rtsp.py 让相机启动了一个服务然后在电脑上接收这个视频流并且跑opencv 内置的标定程序:
set OPENCV_FFMPEG_CAPTURE_OPTIONS="rtsp_transport;tcp"
opencv_interactive-calibration -t=chessboard -w=9 -h=6 -sz=0.025 -v="http://192.168.1.81:8000/stream" 2>nul

View File

@@ -29,8 +29,14 @@
从日志看就是开始发送登录信息之后就崩溃了。出发了底层的read failed。经过排查是一定要插上电源板的数据连线以及电源板要插上电池。这个应该是 从日志看就是开始发送登录信息之后就崩溃了。出发了底层的read failed。经过排查是一定要插上电源板的数据连线以及电源板要插上电池。这个应该是
登录时需要读电源电压数据, 登录时需要读电源电压数据,
3. 问题描述202609 批次的拓展版有一块maixcam的蓝灯常亮询问maixcam的人他们觉得应该是卡没有插好。但是拓展版上的激光口挡住了数据卡的出口 3. a问题描述202609 批次的拓展版有一块maixcam的蓝灯常亮询问maixcam的人他们觉得应该是卡没有插好。但是拓展版上的激光口挡住了数据卡的出口
没法拔出检查, 没法拔出检查,
解决方案:需要做拓展版的公司(深链鑫创)在做好板子之后,确定系统能正常启动 解决方案:需要做拓展版的公司(深链鑫创)在做好板子之后,确定系统能正常启动
4.
b问题描述2022609 批次的拓展板有一次maixcam的蓝灯亮的时候很长不会闪烁后面把sd卡插进去一点又恢复正常了初步怀疑是射箭时没有缓冲
导致了sd 卡被撞松了
4. 问题描述4G模块不可用模块的绿灯没有闪亮
解决方案有这样的一种情况就是4G模块的天线触碰到了旁边的电容导致短路所以模块启动失败。需要保证电容和天线的金属头不会触碰
5.

View File

@@ -103,3 +103,174 @@ WiFi 连接成功
上层检测到连接断开: 上层检测到连接断开:
重新 connect_server() → 自动选择 4G 重新 connect_server() → 自动选择 4G
10. 现在使用的相机其实是支持更大的分辨率的比如说1920*1280但是由于我们的图像处理拍照处理之后很容易触发OOM。
11. 环数计算流程:
现在设备侧的目标是:算出箭点相对靶心的偏移(dx,dy)单位是物理厘米cm然后把它作为 x,y 上报给后端;后端再去算环。
设备侧本身不直接算环数,它算的是偏移与距离,并上报。
算法流程(一次射箭从触发到上报)
1) 触发后取一帧图
在 process_shot() 里读取相机帧并调用 analyze_shot(frame)
2) 确定激光点laser_point
analyze_shot() 第一步先确定激光点 (x,y)(像素坐标):
硬编码config.HARDCODE_LASER_POINT=True → 用 laser_manager.laser_point
已校准laser_manager.has_calibrated_point() → 用校准值
动态模式:先 detect_circle_v3(frame, None) 粗估距离,再根据距离反推激光点
代码在:
if config.HARDCODE_LASER_POINT:
...
elif laser_manager.has_calibrated_point():
...
else:
_, _, _, _, best_radius1_temp, _ = detect_circle_v3(frame, None)
distance_m_first = estimate_distance(best_radius1_temp) ...
laser_point = laser_manager.calculate_laser_point_from_distance(distance_m_first)
3) 优先走三角形路径(成功就直接用于上报 x/y
如果 config.USE_TRIANGLE_OFFSET=True先尝试识别靶面四角三角形标记
if getattr(config, "USE_TRIANGLE_OFFSET", False):
K, dist_coef, pos = _get_triangle_calib()
img_rgb = image.image2cv(frame, False, False)
tri = try_triangle_scoring(img_rgb, (x, y), pos, K, dist_coef, ...)
if tri.get("ok"):
return {... "dx": tri["dx_cm"], "dy": tri["dy_cm"], "distance_m": tri.get("distance_m"), ...}
这一步里 try_triangle_scoring() 做了两件事(都在 triangle_target.py
单应性homography把激光点从图像坐标映射到靶面坐标系得到(dx,dy)cm
PnP用识别到的角点与相机标定估算 相机到靶的距离 distance_m
关键代码:
ok_h, tx, ty, _H = homography_calibration(...)
out["dx_cm"] = tx
out["dy_cm"] = -ty
out["distance_m"] = dist_m
out["distance_method"] = "pnp_triangle"
注意:这里 dy_cm 取了负号是为了和现网约定一致laser_manager.compute_laser_position 的坐标方向)。
4) 三角形失败 → 回退圆形/椭圆靶心检测(兜底)
如果三角形不可用或识别失败,就走传统靶心检测:
detect_circle_v3(frame, laser_point) 找黄心/红心、半径、椭圆参数
用 laser_manager.compute_laser_position() 把像素偏移换算成厘米偏移(dx,dy)
在 shoot_manager.py
result_img, center, radius, method, best_radius1, ellipse_params = detect_circle_v3(frame, laser_point)
if center and radius:
dx, dy = laser_manager.compute_laser_position(center, (x, y), radius, method)
distance_m = estimate_distance(best_radius1) ...
在 laser_manager.compute_laser_position()(核心换算逻辑):
r = radius * 5
target_x = (lx-cx)/r*100
target_y = (ly-cy)/r*100
return (target_x, -target_y)
这里 (像素差)/(radius*5)*100 是你们旧约定下的“像素→厘米”比例模型(并且 y 方向同样取负号)。
5) 上报数据:把(dx,dy) 作为 x/y 发给后端
最终上报发生在 process_shot(),直接把 dx,dy 填到 inner_data["x"],["y"]
srv_x = round(float(dx), 4) if dx is not None else 200.0
srv_y = round(float(dy), 4) if dy is not None else 200.0
inner_data = {
"x": srv_x,
"y": srv_y,
"d": round((distance_m or 0.0) * 100),
"m": method if method else "no_target",
"offset_method": offset_method,
"distance_method": distance_method,
...
}
network_manager.safe_enqueue(...)
x,y物理厘米cm
d相机到靶距离m→cm乘 100三角形成功时来自 PnP
m/offset_method/distance_method标记本次用的算法路径triangle / yellow / pnp 等)
后端收到 x,y 后,再用你之前给的 Go 公式 CalculateRingNumber(x,y,tenRingRadius) 计算环数。
你现在的“环数计算”实际依赖关系
最好路径(快+稳):三角形 → dx,dy单应性 + distance_mPnP
兜底路径:圆/椭圆靶心 → dx,dy基于黄心半径比例/透视校正) + distance_m黄心半径估距
12. 4g模块上传文件
Upload images from MaixCam to Qiniu cloud via ML307R 4G module's AT commands. The HTTP body requires multipart/form-data with real CR/LF bytes (0x0D 0x0A) in boundaries.
Methods Tried
# Method AT Commands Result Root Cause
1 Raw binary, no encoding MHTTPCONTENT with raw bytes + length param ERROR at first chunk CR/LF in binary data terminates AT command parser
2 Encoding mode 2 (escape) MHTTPCFG="encoding",0,2 + \r\n escapes Server 400 Bad Request Module sends literal text \r\n to server, NOT actual 0x0D 0x0A bytes. Multipart body is garbled
3 Encoding mode 1 (hex) MHTTPCFG="encoding",0,1 + hex-encoded data CME ERROR: 650/50 Firmware doesn't properly support hex mode for MHTTPCONTENT
4 No chunked mode Skip MHTTPCFG="chunked" CME ERROR: 65 Module requires chunked mode to accept MHTTPCONTENT at all
5 Single large MHTTPCONTENT All data in one command (2793 bytes) +MHTTPURC: "err",0,5 (timeout) Possible buffer limit; module hangs then times out
6 Per-chunk HTTP instance (OTA style) CREATE→POST→DELETE per chunk Not feasible Each instance = separate HTTP request; Qiniu needs complete body in single POST
Conclusion: AT HTTP layer (MHTTPCONTENT) is fundamentally broken for binary uploads.
The Solution: Raw TCP Socket (MIPOPEN + MIPSEND)
Bypass the AT HTTP layer entirely. Open a raw TCP connection and send a hand-crafted HTTP POST:
plaintext
AT+MIPCLOSE=3 // Clean up old socket
AT+MIPOPEN=3,"TCP","upload.qiniup.com",80 // Raw TCP connection
AT+MIPSEND=3,1024 → ">" → [raw bytes] → OK // Binary-safe!
AT+MIPSEND=3,1024 → ">" → [raw bytes] → OK
AT+MIPSEND=3,766 → ">" → [raw bytes] → OK
// Response: +MIPURC: "rtcp",3,<len>,HTTP/1.1 200 OK...
AT+MIPCLOSE=3
Why it works:
MIPSEND enters prompt mode (>) — after the >, the AT parser treats ALL bytes as data, including CR/LF
We construct the complete HTTP request ourselves (headers + Content-Length + multipart body) with real CRLF bytes
Key bug found during integration: _send_chunk() wrapped calls in self.at._cmd_lock, but self.at.send() also acquires the same lock internally — threading.Lock() is not reentrant, causing deadlock. Fixed by removing the outer lock (the network_manager.get_uart_lock() already provides thread safety).Trade-off: UART is locked during the entire upload, so heartbeats pause. For small JPEG files (~2-80KB), this is 5-20 seconds — acceptable if server heartbeat timeout is generous
13. 算环数算法1「黄心 + 红心」椭圆/圆:主要在 vision.py 的 detect_circle_v3() 里完成:颜色先用 HSV 做掩码,再在轮廓上做面积、圆度筛选,黄圈用椭圆拟合,红圈预先筛成候选,最后用几何关系配对。
1. 黄色怎么判、范围是什么?
图像先转 HSVcv2.COLOR_RGB2HSV注意输入是 RGB
饱和度 S 整体乘 1.1 并限制在 0255让黄色更「显」一点
黄色 inRangeOpenCV HSVH 多为 0179
通道 下限 上限
H 7 32
S 80 255
V 0 255
在黄掩码上找轮廓后,还要满足:面积 > 50圆度 > 0.7circularity = 4π·面积/周长²),且点数 ≥5 才 fitEllipse 当黄心椭圆。
2. 红色怎么判、范围是什么?
红色在 HSV 里跨 0°所以用 两段 H 做并集:
两段分别是:
H 010S 80255V 0255
H 170180S 80255V 0255
红轮廓候选:面积 > 50圆度 > 0.6(比黄略松),再拟合椭圆或最小外接圆得到圆心和半径。
3. 「黄心」和「红心」怎样算一对?(几何范围)
对每个黄圈,在红色候选里找第一个满足:
两圆心距离 dist_centers < yellow_radius * 1.5
红半径 red_radius > yellow_radius * 0.8(红在外圈、略大)
dist_centers = math.hypot(ddx, ddy)
if dist_centers < yellow_radius * 1.5 and rc["radius"] > yellow_radius * 0.8:
小结黄色 = HSV H∈[7,32]、S80 S 放大 1.1+ 形态学闭运算 + 面积/圆度红色 = 两段 H010 170180)、S80 + 闭运算 + 面积/圆度配对用 同心/包含 的距离与半径比例阈值若你还关心 laser_manager.py 激光红点的另一套阈值LASER_*那是另一条链路和靶心黄/ HSV 可以分开看
14. 算环数算法2
使用单应性矩阵计算镜头中心点照片中心像素到虚拟平面的转换它不需要知道相机在 3D 空间中的具体位置直接通过单应性矩阵 H的逆运算 2D 像素翻译成虚拟平面上的 2D 坐标
转换的本质2D 2D 查字典
单应性变换Homography是平面到平面的映射它不处理 3D 空间中的投影线”,而是直接建立图像像素 (u,v) 与虚拟平面坐标 (x,y) 的一一对应关系
你可以把单应性矩阵 H想象成一本翻译字典”:
正变换 H已知靶纸上的真实位置 (x,y)查字典得到它在照片上哪个像素 (u,v)。
逆变换 H1已知照片上的像素 (u,v)如镜头中心点查字典反推它在靶纸上的真实位置 (x,y)。
这个虚拟平面就是你的靶纸平面Z=0 的世界坐标系)。算法没有在物理上移动任何点只是在做坐标系的换算
详细步骤镜头中心点如何落地
相机分辨率是 640x480镜头中心点光轴与图像的交点通常是 (u0,v0)=(320,240)。
1. 输入镜头中心点像素
2. 核心运算乘以逆矩阵
通过 4 个黑色三角形的角点已知真实坐标计算出了单应性矩阵 H现在使用它的逆矩阵 H1
3. 输出虚拟平面上的落点物理坐标
计算后你会得到(xhit,yhit)
这就是镜头中心点对应的靶纸上的真实位置单位毫米)。
4. 计算环数
由于虚拟平面原点 (0,0)就是靶纸圆心直接计算欧氏距离。​
这个 d就是箭着点偏离圆心的真实物理距离直接用于环数判定

View File

@@ -1,17 +1,6 @@
你现在要防的是“别人拿到设备/拿到代码包后,能伪造请求、刷接口、下发恶意 OTA、甚至劫持通信”。单靠隐藏 Python 源码只能提高门槛,真正的安全要靠协议和密钥设计。结合你仓库里实际内容,建议你重点隐藏/整改这些点(按风险排序)。 你现在要防的是“别人拿到设备/拿到代码包后,能伪造请求、刷接口、下发恶意 OTA、甚至劫持通信”。单靠隐藏 Python 源码只能提高门槛,真正的安全要靠协议和密钥设计。结合你仓库里实际内容,建议你重点隐藏/整改这些点(按风险排序)。
1. 必须隐藏/必须整改(高风险)
1.1 登录口令规则太弱(几乎等于明文)
你现在的登录是 password = device_id + "."(见 network.py 读取设备 ID 后直接拼出来),这意味着只要攻击者知道/猜到 device_id就能直接登录伪装设备。
相关位置:
with open("/device_key", "r") as f: device_id = f.read().strip() ... self._device_id = device_id self._password = device_id + "."
1.2 HTTP 鉴权 token 的盐值是硬编码常量(泄露后可离线伪造)
你 token 是 HMAC-SHA256((SALT+device_id), SALT2),而 SALT/SALT2 是固定字符串:"shootMessageFire" / "shoot"。这类“硬编码盐值 + 可猜/可读的 device_id”意味着攻击者只要拿到代码包/逆向 .so就能在自己电脑上批量算 token伪造 HTTP 请求。
相关位置:
SALT = "shootMessageFire"SALT2 = "shoot"return "Arrow_" + hmac.new((SALT + device_id).encode(), SALT2.encode(), hashlib.sha256).hexdigest()
1.3 TLS 配置目前看起来没有做证书校验(容易被中间人攻击)
config.py 虽然 USE_TCP_SSL=True但你在 network.py 里实际把 MSSLCFG="auth" 固定成 0不验且写证书分支被 if False 禁用了。这样“看起来是 TLS”但仍可能被抓包/篡改/假服务器接入。
相关位置:
r = hardware_manager.at_client.send(f'AT+MSSLCFG="auth",{ssl_id},0', "OK", 3000)...if False: # 写证书/校验被禁用 ...r = hardware_manager.at_client.send(f'AT+MIPCFG="ssl",{link_id},{ssl_id},1', "OK", 3000)
1.4 OTA 下发“url”如果缺少强校验就是远程代码执行入口 1.4 OTA 下发“url”如果缺少强校验就是远程代码执行入口
你 OTA 逻辑里从服务器指令拿到 url 就去下载并替换文件/重启(这是正常 OTA但安全性取决于 你 OTA 逻辑里从服务器指令拿到 url 就去下载并替换文件/重启(这是正常 OTA但安全性取决于
是否只允许白名单域名/路径 是否只允许白名单域名/路径
@@ -24,18 +13,13 @@ r = hardware_manager.at_client.send(f'AT+MSSLCFG="auth",{ssl_id},0', "OK", 3000)
这些在 config.py 是明文: 这些在 config.py 是明文:
SERVER_IP = "stcp.shelingxingqiu.com"SERVER_PORT = 50005HTTP_URL = "http://ws.shelingxingqiu.com"HTTP_API_PATH = "/home/shoot/device_fire/arrow/fire" SERVER_IP = "stcp.shelingxingqiu.com"SERVER_PORT = 50005HTTP_URL = "http://ws.shelingxingqiu.com"HTTP_API_PATH = "/home/shoot/device_fire/arrow/fire"
注意:即使你把它们藏进 .so攻击者仍可能通过抓包/观察 DNS/SNI/流量拿到域名或 IP所以“隐藏”只能降低静态分析风险不能替代鉴权/签名。 注意:即使你把它们藏进 .so攻击者仍可能通过抓包/观察 DNS/SNI/流量拿到域名或 IP所以“隐藏”只能降低静态分析风险不能替代鉴权/签名。
2.2 WiFi 凭证落盘位置
你会把 SSID/密码写到 /boot/wifi.ssid 和 /boot/wifi.passnetwork.py/wifi.py 都有)。拿到设备存储就能读到明文密码,这属于设备侧安全问题。
2.3 日志/调试信息泄露
你仓库里 .cursor/debug.log* 已经记录了完整的 AT+MIPOPEN ... host:port 等信息;如果这些被打进包或被用户导出,也会泄露通信细节。
你发布包里“真正会被带走的敏感内容”
你现在 app.yaml 的打包清单只包含核心运行文件(不包含 test/、backup_code/、.cursor/ 等),这一点很好:
files: - app.yaml - at_client.py - camera_manager.py - config.py - hardware.py - laser_manager.py - logger_manager.py - main.py - network.py - ota_manager.py - power.py - shot_id_generator.py - time_sync.py - version.py - vision.py
但要注意:你当前最敏感的“盐值/口令规则/HTTP header 逻辑/OTA 策略”都在这些会打包的文件里(尤其是 network.py、ota_manager.py、config.py
3. 最有效的“防黑”措施(比隐藏更关键) 3. 最有效的“防黑”措施(比隐藏更关键)
把“password=device_id+.” 改为真正的 per-device secret设备出厂烧录服务端保存或服务端下发公钥设备用私钥签名
所有上报/控制命令加签名 + nonce/timestamp + 服务端防重放(别人抓到一次包也不能复用) 所有上报/控制命令加签名 + nonce/timestamp + 服务端防重放(别人抓到一次包也不能复用)
OTA 包必须做签名校验(设备端内置公钥,下载后验签通过才应用) OTA 包必须做签名校验(设备端内置公钥,下载后验签通过才应用)
TLS 必须做证书校验/最好做 pinning至少别用 auth=0
如果你告诉我:你们服务端目前能不能改协议(例如新增签名字段、下发 challenge、做 OTA 签名),我可以按“最小改动但提升最大安全”的顺序,帮你规划一套从现状平滑升级的方案。 如果你告诉我:你们服务端目前能不能改协议(例如新增签名字段、下发 challenge、做 OTA 签名),我可以按“最小改动但提升最大安全”的顺序,帮你规划一套从现状平滑升级的方案。

172
main.py
View File

@@ -21,13 +21,14 @@ 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 init_ina226, get_bus_voltage, voltage_to_percent from power import init_ina226
from laser_manager import laser_manager from laser_manager import laser_manager
from vision import detect_circle_v3, estimate_distance, enqueue_save_shot, start_save_shot_worker from vision import start_save_shot_worker
from network import network_manager from network import network_manager
from ota_manager import ota_manager from ota_manager import ota_manager
from hardware import hardware_manager from hardware import hardware_manager
from camera_manager import camera_manager from camera_manager import camera_manager
from shoot_manager import process_shot, preload_triangle_calib
def laser_calibration_worker(): def laser_calibration_worker():
@@ -99,7 +100,7 @@ def cmd_str():
init_ina226() init_ina226()
# 4. 初始化显示和相机 # 4. 初始化显示和相机
camera_manager.init_camera(640, 480) camera_manager.init_camera(getattr(config, "CAMERA_WIDTH", 640), getattr(config, "CAMERA_HEIGHT", 480))
camera_manager.init_display() camera_manager.init_display()
# ==================== 第二阶段:软件初始化 ==================== # ==================== 第二阶段:软件初始化 ====================
@@ -115,9 +116,24 @@ def cmd_str():
# 2. 从4G模块同步系统时间需要 at_client 已初始化) # 2. 从4G模块同步系统时间需要 at_client 已初始化)
sync_system_time_from_4g() sync_system_time_from_4g()
# 2.1 WiFi 热点配网兜底:仅当 STA 与 4G 均不可用时起 AP + HTTP提交后删 /boot/wifi.ap、建 wifi.sta 并 reboot
try:
from wifi_config_httpd import maybe_start_wifi_ap_fallback
maybe_start_wifi_ap_fallback(logger)
except Exception as e:
if logger:
logger.error(f"[WIFI-AP] 兜底配网检测/启动失败: {e}")
# 2.5. 启动存图 worker 线程(队列 + worker避免主循环阻塞 # 2.5. 启动存图 worker 线程(队列 + worker避免主循环阻塞
start_save_shot_worker() start_save_shot_worker()
# 2.6 预加载三角形标定/坐标文件(避免首次射箭卡顿)
try:
preload_triangle_calib()
except Exception:
pass
# 3. 启动时检查:是否需要恢复备份 # 3. 启动时检查:是否需要恢复备份
pending_path = f"{config.APP_DIR}/ota_pending.json" pending_path = f"{config.APP_DIR}/ota_pending.json"
if os.path.exists(pending_path): if os.path.exists(pending_path):
@@ -204,7 +220,16 @@ def cmd_str():
pass pass
# 6. 启动通信与校准线程 # 6. 启动通信与校准线程
_thread.start_new_thread(network_manager.tcp_main, ()) # 若已进入 AP 配网兜底(/boot/wifi.ap则不启动 TCP 主循环;等待用户配网后 reboot。
try:
if os.path.exists("/boot/wifi.ap"):
if logger:
logger.warning("[NET] 当前处于 AP 配网模式(/boot/wifi.ap 存在),跳过 TCP 主线程启动")
else:
_thread.start_new_thread(network_manager.tcp_main, ())
except Exception as e:
if logger:
logger.error(f"[NET] 启动 TCP 主线程失败: {e}")
if not config.HARDCODE_LASER_POINT: if not config.HARDCODE_LASER_POINT:
_thread.start_new_thread(laser_calibration_worker, ()) _thread.start_new_thread(laser_calibration_worker, ())
@@ -330,144 +355,7 @@ def cmd_str():
_flush_pressure_buf("before_trigger") _flush_pressure_buf("before_trigger")
try: try:
frame = camera_manager.read_frame() process_shot(adc_val)
laser_point_method = None # 记录激光点选择方法
if config.HARDCODE_LASER_POINT:
# 硬编码模式:使用硬编码值
laser_point = laser_manager.laser_point
laser_point_method = "hardcode"
elif laser_manager.has_calibrated_point():
# 假如校准过,并且有保存值,使用校准值
laser_point = laser_manager.laser_point
laser_point_method = "calibrated"
logger_manager.logger.info(f"[算法] 使用校准值: {laser_manager.laser_point}")
elif distance_m and distance_m > 0:
# 动态计算模式:根据距离计算激光点
# 先检测靶心以获取距离(用于计算激光点)
# 第一次检测不使用激光点,仅用于获取距离
result_img_temp, center_temp, radius_temp, method_temp, best_radius1_temp, ellipse_params_temp = detect_circle_v3(frame, None)
# 计算距离
distance_m = estimate_distance(best_radius1_temp) if best_radius1_temp else None
laser_point = laser_manager.calculate_laser_point_from_distance(distance_m)
laser_point_method = "dynamic"
if laser_point is None:
logger = logger_manager.logger
if logger:
logger.warning("[MAIN] 激光点未初始化,跳过本次检测")
time.sleep_ms(100)
continue
x, y = laser_point
# 检测靶心
result_img, center, radius, method, best_radius1, ellipse_params = detect_circle_v3(frame, laser_point)
if config.SHOW_CAMERA_PHOTO_WHILE_SHOOTING:
camera_manager.show(result_img)
# 计算偏移与距离(如果检测到靶心)
if center and radius:
dx, dy = laser_manager.compute_laser_position(center, (x, y), radius, method)
distance_m = estimate_distance(best_radius1)
else:
# 未检测到靶心
dx, dy = None, None
distance_m = None
if logger:
logger.warning("[MAIN] 未检测到靶心,但会保存图像")
# 快速激光测距激光一闪而过约500-600ms
laser_distance_m = None
laser_signal_quality = 0
# try:
# result = laser_manager.quick_measure_distance()
# if isinstance(result, tuple) and len(result) == 2:
# laser_distance_m, laser_signal_quality = result
# else:
# # 向后兼容:如果返回的是单个值
# laser_distance_m = result if isinstance(result, (int, float)) else 0.0
# laser_signal_quality = 0
# if logger:
# if laser_distance_m > 0:
# logger.info(f"[MAIN] 激光测距成功: {laser_distance_m:.3f} m, 信号质量: {laser_signal_quality}")
# else:
# logger.warning("[MAIN] 激光测距失败或返回0")
# except Exception as e:
# if logger:
# logger.error(f"[MAIN] 激光测距异常: {e}")
# 读取电量
voltage = get_bus_voltage()
battery_percent = voltage_to_percent(voltage)
# 生成射箭ID
from shot_id_generator import shot_id_generator
shot_id = shot_id_generator.generate_id() # 不需要使用device_id
# 构造上报数据
inner_data = {
"shot_id": shot_id, # 射箭ID用于关联图片和服务端日志
"x": float(dx) if dx is not None else 200.0,
"y": float(dy) if dy is not None else 200.0,
"r": 90.0,
"d": round((distance_m or 0.0) * 100), # 视觉测距值(厘米)
"d_laser": round((laser_distance_m or 0.0) * 100), # 激光测距值(厘米)
"d_laser_quality": laser_signal_quality, # 激光测距信号质量
"m": method if method else "no_target",
"adc": adc_val,
# 新增字段:激光点选择方法
"laser_method": laser_point_method, # 激光点选择方法hardcode/calibrated/dynamic/default
# 激光点坐标(像素)
"target_x": float(x), # 激光点 X 坐标(像素)
"target_y": float(y), # 激光点 Y 坐标(像素)
}
# 添加椭圆参数(如果存在)
if ellipse_params:
(ell_center, (width, height), angle) = ellipse_params
inner_data["ellipse_major_axis"] = float(max(width, height)) # 长轴(像素)
inner_data["ellipse_minor_axis"] = float(min(width, height)) # 短轴(像素)
inner_data["ellipse_angle"] = float(angle) # 椭圆角度(度)
inner_data["ellipse_center_x"] = float(ell_center[0]) # 椭圆中心 X 坐标(像素)
inner_data["ellipse_center_y"] = float(ell_center[1]) # 椭圆中心 Y 坐标(像素)
else:
inner_data["ellipse_major_axis"] = None
inner_data["ellipse_minor_axis"] = None
inner_data["ellipse_angle"] = None
inner_data["ellipse_center_x"] = None
inner_data["ellipse_center_y"] = None
report_data = {"cmd": 1, "data": inner_data}
network_manager.safe_enqueue(report_data, msg_type=2, high=True)
# 闪一下激光(射箭反馈)
if config.FLASH_LASER_WHILE_SHOOTING:
laser_manager.flash_laser(config.FLASH_LASER_DURATION_MS)
# 保存图像(无论是否检测到靶心都保存):放入队列由 worker 异步保存,不阻塞主循环
enqueue_save_shot(
result_img,
center,
radius,
method,
ellipse_params,
(x, y),
distance_m,
shot_id=shot_id,
photo_dir=config.PHOTO_DIR if config.SAVE_IMAGE_ENABLED else None,
)
if center and radius:
logger.info(f"射箭事件已加入发送队列已检测到靶心ID: {shot_id}")
else:
logger.info(f"射箭事件已加入发送队列未检测到靶心已保存图像ID: {shot_id}")
time.sleep_ms(100)
except Exception as e: except Exception as e:
logger = logger_manager.logger logger = logger_manager.logger
if logger: if logger:

1140
network.py

File diff suppressed because it is too large Load Diff

33
server.pem Normal file
View File

@@ -0,0 +1,33 @@
-----BEGIN CERTIFICATE-----
MIIFwjCCA6qgAwIBAgIUAZIGjFLTekYI+IIquQ/87qLDuNAwDQYJKoZIhvcNAQEL
BQAwXjELMAkGA1UEBhMCQ04xDjAMBgNVBAgMBUxvY2FsMQ4wDAYDVQQHDAVMb2Nh
bDEOMAwGA1UECgwFTG9jYWwxHzAdBgNVBAMMFnd3dy5zaGVsaW5neGluZ3FpdS5j
b20wIBcNMjYwNDA3MDc0NDI2WhgPMjEyNjAzMTQwNzQ0MjZaMF4xCzAJBgNVBAYT
AkNOMQ4wDAYDVQQIDAVMb2NhbDEOMAwGA1UEBwwFTG9jYWwxDjAMBgNVBAoMBUxv
Y2FsMR8wHQYDVQQDDBZ3d3cuc2hlbGluZ3hpbmdxaXUuY29tMIICIjANBgkqhkiG
9w0BAQEFAAOCAg8AMIICCgKCAgEAvKRcWr8QeT1OzhMbWlHmqxmduE+e7r2Oet9I
mU4O888U1X1YKaIDnq+zqRCNteid3jrOWucDLReZzNnrZ4l3Jq9nbWuTwj9Y9vCq
ahW3K3BOhnuJ+qvqX2Izn1Z9iNCFhXnUaFy8+iP0nJNNIRXwg7ioKbY6+SaTbBzI
vfG33MjOmwnQlqZzdGyNpvieO9XzqVyRxeDen/LJf4Z1NocP2rOjqQC3dIDXOfBt
/ZOZymb4XwQ9b/t+6WJn9Zfycw0tp/7GqI+vqLDUMpipO4ahmybJPO02IhokZ09t
BnCXe0enLnMAshIipTxSaJEick9HnQVSUzF+9A1F0cCFAhS8cM/04aksfYsJD2xj
riiVHVoVo6tb0GJSCM+b0j9ObH9bDx3DKfy9EcqP25mJxWQTuT8G0oiyuxE5knjA
HL7yjwd5gVSuig+ACnxE3vITeVKtvyep7sD4tJqkN93t7OMeBRFMGsYpJ8w+8u6X
+9/RmMcOnuNcT/4HrOuAtlAnM1D44MSI1RLaOCJJ9evqhpWdktfn2Uv4gCnaTjUr
OiEU/G+lquST2kggjbcReLqkk+7yN3XkaR9dun4iV35WfEo1ENThVhLPGV61LaJq
PwbjltQlkcAFPJ1GJyE9FVO79bB51d0w/rlI/CcDUpTRMaXR35EmTjxvXOr/a/XI
56GUNaUCAwEAAaN2MHQwHQYDVR0OBBYEFH1HCDm4N7LMhIX2Fb2FXAfdyhwQMB8G
A1UdIwQYMBaAFH1HCDm4N7LMhIX2Fb2FXAfdyhwQMA8GA1UdEwEB/wQFMAMBAf8w
IQYDVR0RBBowGIIWd3d3LnNoZWxpbmd4aW5ncWl1LmNvbTANBgkqhkiG9w0BAQsF
AAOCAgEAG/PMwXCXJOaqCpU/LaY6w04ue6wk95RbPXf4JH4CrrLUfgyUmFlNNQPA
LuZSBRI6KUGkTvzuz/3ofZHVEin3CyE5NadB3UItpfA4Wl4r3jMPifIgnA/NT8xo
GE1gYaDbcfJNE8jy6GebjZekbVrPvCY9YgcUT2AmW5fcbnCTy+/iC7lf9MvvqHTJ
H5zvOp5nyWJYWYsvvif3Y7dp00ytg9I8/LSgUspKwB8qSWPWV8z4WsV6sc1mNqVS
nFBDkgzZxr4ZYlhVLzbSoab8D4A/z6riEMqv4S+oF5VkaJLhsN8vgHh9aPspCC3Q
zhcosH8XmNmJmT/X64FhhRqxAqX65WanVQABtBS/vsC+FAQDGMb3RkZSbLEnIlgj
bx/6bSkhHl+J2xIqA7tLvYhRSvM3H12X7VSVc+tkVzI5JoUSugZLxxRDGpYgkvRz
SPFCqb9eTn5ES5gnQX6+E+f/E/WQTmadolSbEppdxNZW7AaIUdQo0aFxFwctwhA2
YNUG9oW2TXAZjSECyTo28NFkFfwBhpHWigFCANNCd8Nrn0k0YMuJOkqW5e4w3/24
/IxM/C9K7aAx4S1XZ16Nvh5pZQduEGKTSUYMJ/uV26Mf4ZGroUfGB9tBguK5rYbL
UlRvtU9mkZPK04GbLsoo+8tZTDRtkuCiC19xk33XiitZrmavc24=
-----END CERTIFICATE-----

View File

@@ -1,11 +1,44 @@
import os
import threading
import config import config
from camera_manager import camera_manager from camera_manager import camera_manager
from laser_manager import laser_manager 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 power import get_bus_voltage, voltage_to_percent from triangle_target import load_camera_from_xml, load_triangle_positions, try_triangle_scoring
from vision import estimate_distance, detect_circle_v3, save_shot_image from vision import estimate_distance, detect_circle_v3, enqueue_save_shot
from maix import camera, display, image, app, time, uart, pinmap, i2c from maix import image, time
# 缓存相机标定与三角形位置,避免每次射箭重复读磁盘
_tri_calib_cache = None
def _get_triangle_calib():
"""返回 (K, dist, marker_positions);首次调用时从磁盘加载并缓存。"""
global _tri_calib_cache
if _tri_calib_cache is not None:
return _tri_calib_cache
calib_path = getattr(config, "CAMERA_CALIB_XML", "")
tri_json = getattr(config, "TRIANGLE_POSITIONS_JSON", "")
if not (os.path.isfile(calib_path) and os.path.isfile(tri_json)):
_tri_calib_cache = (None, None, None)
return _tri_calib_cache
K, dist = load_camera_from_xml(calib_path)
pos = load_triangle_positions(tri_json)
_tri_calib_cache = (K, dist, pos)
return _tri_calib_cache
def preload_triangle_calib():
"""
启动阶段预加载三角形标定与坐标文件,避免首次射箭触发时的读盘/解析开销。
"""
try:
_get_triangle_calib()
except Exception:
# 预加载失败不影响主流程;射箭时会再次按需尝试
pass
def analyze_shot(frame, laser_point=None): def analyze_shot(frame, laser_point=None):
""" """
@@ -13,18 +46,18 @@ def analyze_shot(frame, laser_point=None):
:param frame: 图像帧 :param frame: 图像帧
:param laser_point: 激光点坐标 (x, y) :param laser_point: 激光点坐标 (x, y)
:return: 包含分析结果的字典 :return: 包含分析结果的字典
优先级:
1. 三角形单应性USE_TRIANGLE_OFFSET=True 时)— 成功则直接返回,跳过圆形检测
2. 圆形检测(三角形不可用或识别失败时兜底)
""" """
logger = logger_manager.logger logger = logger_manager.logger
from datetime import datetime
# 先检测靶心以获取距离(用于计算激光点) # ── Step 1: 确定激光点 ────────────────────────────────────────────────────
result_img_temp, center_temp, radius_temp, method_temp, best_radius1_temp, ellipse_params_temp = detect_circle_v3(
frame, None)
# 计算距离
distance_m = estimate_distance(best_radius1_temp) if best_radius1_temp else None
# 根据距离动态计算激光点坐标
laser_point_method = None laser_point_method = None
distance_m_first = None
if config.HARDCODE_LASER_POINT: if config.HARDCODE_LASER_POINT:
laser_point = laser_manager.laser_point laser_point = laser_manager.laser_point
laser_point_method = "hardcode" laser_point_method = "hardcode"
@@ -33,65 +66,143 @@ def analyze_shot(frame, laser_point=None):
laser_point_method = "calibrated" laser_point_method = "calibrated"
if logger: if logger:
logger.info(f"[算法] 使用校准值: {laser_manager.laser_point}") logger.info(f"[算法] 使用校准值: {laser_manager.laser_point}")
elif distance_m and distance_m > 0:
laser_point = laser_manager.calculate_laser_point_from_distance(distance_m)
laser_point_method = "dynamic"
if logger:
logger.info(f"[算法] 使用比例尺: {laser_point}")
else: else:
laser_point = laser_manager.laser_point # 动态模式:先做一次无激光点检测以估算距离,再推算激光点
laser_point_method = "default" _, _, _, _, best_radius1_temp, _ = detect_circle_v3(frame, None)
if logger: distance_m_first = estimate_distance(best_radius1_temp) if best_radius1_temp else None
logger.info(f"[算法] 使用默认值: {laser_point}") if distance_m_first and distance_m_first > 0:
laser_point = laser_manager.calculate_laser_point_from_distance(distance_m_first)
laser_point_method = "dynamic"
if logger:
logger.info(f"[算法] 使用比例尺: {laser_point}")
else:
laser_point = laser_manager.laser_point
laser_point_method = "default"
if logger:
logger.info(f"[算法] 使用默认值: {laser_point}")
if laser_point is None: if laser_point is None:
return { return {"success": False, "reason": "laser_point_not_initialized"}
"success": False,
"reason": "laser_point_not_initialized"
}
x, y = laser_point x, y = laser_point
# 绘制激光十字线 # ── Step 2: 提前转换一次图像,两个检测线程共享(只读)────────────────────────
color = image.Color(config.LASER_COLOR[0], config.LASER_COLOR[1], config.LASER_COLOR[2]) img_cv = image.image2cv(frame, False, False)
frame.draw_line(
int(x - config.LASER_LENGTH), int(y),
int(x + config.LASER_LENGTH), int(y),
color, config.LASER_THICKNESS
)
frame.draw_line(
int(x), int(y - config.LASER_LENGTH),
int(x), int(y + config.LASER_LENGTH),
color, config.LASER_THICKNESS
)
frame.draw_circle(int(x), int(y), 1, color, config.LASER_THICKNESS)
# 重新检测靶心(使用计算出的激光点) # ── Step 3: 检查三角形是否可用 ────────────────────────────────────────────────
result_img, center, radius, method, best_radius1, ellipse_params = detect_circle_v3(frame, laser_point) use_tri = getattr(config, "USE_TRIANGLE_OFFSET", False)
K = dist_coef = pos = None
if use_tri:
K, dist_coef, pos = _get_triangle_calib()
use_tri = K is not None and dist_coef is not None and pos
# 计算偏移与距离 def _build_circle_result(cdata):
if center and radius: """从圆形检测结果构建 analyze_shot 返回值。"""
dx, dy = laser_manager.compute_laser_position(center, (x, y), radius, method) r_img, center, radius, method, best_radius1, ellipse_params = cdata
distance_m = estimate_distance(best_radius1)
else:
dx, dy = None, None dx, dy = None, None
distance_m = None d_m = distance_m_first
if center and radius:
dx, dy = laser_manager.compute_laser_position(center, (x, y), radius, method)
d_m = estimate_distance(best_radius1) if best_radius1 else distance_m_first
return {
"success": True,
"result_img": r_img,
"center": center, "radius": radius, "method": method,
"best_radius1": best_radius1, "ellipse_params": ellipse_params,
"dx": dx, "dy": dy, "distance_m": d_m,
"laser_point": laser_point, "laser_point_method": laser_point_method,
"offset_method": "yellow_ellipse" if ellipse_params else "yellow_circle",
"distance_method": "yellow_radius",
}
# 返回分析结果 if not use_tri:
return { # 三角形未配置,直接跑圆形检测
"success": True, return _build_circle_result(
"result_img": result_img, detect_circle_v3(frame, laser_point, img_cv=img_cv)
"center": center, )
"radius": radius,
"method": method, # ── Step 4: 三角形 + 圆形并行检测 ─────────────────────────────────────────────
"best_radius1": best_radius1, # 两个线程共享只读的 img_cv互不干扰
"ellipse_params": ellipse_params, tri_result = {}
"dx": dx, circle_result = {}
"dy": dy,
"distance_m": distance_m, def _run_triangle():
"laser_point": laser_point, try:
"laser_point_method": laser_point_method logger.info(f"[TRI] begin {datetime.now()}")
} logger.info(f"[TRI] K: {K}, dist: {dist_coef}, pos: {pos}, {datetime.now()}")
tri = try_triangle_scoring(
img_cv, (x, y), pos, K, dist_coef,
size_range=getattr(config, "TRIANGLE_SIZE_RANGE", (8, 500)),
)
logger.info(f"[TRI] tri: {tri}, {datetime.now()}")
tri_result['data'] = tri
except Exception as e:
logger.error(f"[TRI] 三角形路径异常: {e}")
tri_result['data'] = {'ok': False}
def _run_circle():
try:
circle_result['data'] = detect_circle_v3(frame, laser_point, img_cv=img_cv)
except Exception as e:
logger.error(f"[CIRCLE] 圆形检测异常: {e}")
circle_result['data'] = (frame, None, None, None, None, None)
t_tri = threading.Thread(target=_run_triangle, daemon=True)
t_cir = threading.Thread(target=_run_circle, daemon=True)
t_tri.start()
t_cir.start()
# 最多等待三角形 TRIANGLE_TIMEOUT_MS默认 1000ms
tri_timeout_s = float(getattr(config, "TRIANGLE_TIMEOUT_MS", 1000)) / 1000.0
t_tri.join(timeout=tri_timeout_s)
if t_tri.is_alive():
# 超时:直接放弃三角形结果,回退圆心(圆心线程通常已跑完)
logger.warning(f"[TRI] timeout>{tri_timeout_s:.2f}s回退圆心算法")
t_cir.join()
return _build_circle_result(
circle_result.get('data') or (frame, None, None, None, None, None)
)
tri = tri_result.get('data', {})
# 保险校验:避免三角形返回 nan/inf 或退化点仍被上报
try:
import numpy as _np
tri_ok = bool(tri.get('ok'))
if tri_ok:
dxv = tri.get("dx_cm")
dyv = tri.get("dy_cm")
H = tri.get("homography")
if not _np.isfinite(dxv) or not _np.isfinite(dyv):
tri_ok = False
elif H is not None and not _np.all(_np.isfinite(H)):
tri_ok = False
except Exception:
tri_ok = bool(tri.get('ok'))
if tri_ok:
logger.info(f"[TRI] end {datetime.now()} — 使用三角形结果(dx={tri['dx_cm']:.2f},dy={tri['dy_cm']:.2f}cm)")
return {
"success": True,
"result_img": frame,
"center": None, "radius": None,
"method": "triangle_homography",
"best_radius1": None, "ellipse_params": None,
"dx": tri["dx_cm"], "dy": tri["dy_cm"],
"distance_m": tri.get("distance_m") or distance_m_first,
"laser_point": laser_point, "laser_point_method": laser_point_method,
"offset_method": tri.get("offset_method") or "triangle_homography",
"distance_method": tri.get("distance_method") or "pnp_triangle",
"tri_markers": tri.get("markers", []),
"tri_homography": tri.get("homography"),
}
# 三角形失败,等圆形结果(已并行跑完,几乎无额外等待)
t_cir.join()
logger.info(f"[TRI] end(fallback) {datetime.now()}")
return _build_circle_result(
circle_result.get('data') or (frame, None, None, None, None, None)
)
def process_shot(adc_val): def process_shot(adc_val):
@@ -103,6 +214,7 @@ def process_shot(adc_val):
logger = logger_manager.logger logger = logger_manager.logger
try: try:
network_manager.safe_enqueue({"shoot_event": "start"}, msg_type=2, high=True)
frame = camera_manager.read_frame() frame = camera_manager.read_frame()
# 调用算法分析 # 调用算法分析
@@ -126,16 +238,21 @@ def process_shot(adc_val):
distance_m = analysis_result["distance_m"] distance_m = analysis_result["distance_m"]
laser_point = analysis_result["laser_point"] laser_point = analysis_result["laser_point"]
laser_point_method = analysis_result["laser_point_method"] laser_point_method = analysis_result["laser_point_method"]
offset_method = analysis_result.get("offset_method", "yellow_circle")
distance_method = analysis_result.get("distance_method", "yellow_radius")
tri_markers = analysis_result.get("tri_markers", [])
tri_homography = analysis_result.get("tri_homography")
x, y = laser_point x, y = laser_point
camera_manager.show(result_img) # 三角形路径成功时 center/radius 为空是正常的;此时用 triangle 方法名用于保存文件名与上报字段 m
if (not method) and tri_markers:
method = "triangle_homography"
if not (center and radius) and logger: if config.SHOW_CAMERA_PHOTO_WHILE_SHOOTING:
logger.warning("[MAIN] 未检测到靶心,但会保存图像") camera_manager.show(result_img)
# 读取电量 if dx is None and dy is None and logger:
voltage = get_bus_voltage() logger.warning("[MAIN] 未检测到偏移量(三角形与圆形均失败),但会保存图像")
battery_percent = voltage_to_percent(voltage)
# 生成射箭ID # 生成射箭ID
from shot_id_generator import shot_id_generator from shot_id_generator import shot_id_generator
@@ -144,33 +261,30 @@ def process_shot(adc_val):
if logger: if logger:
logger.info(f"[MAIN] 射箭ID: {shot_id}") logger.info(f"[MAIN] 射箭ID: {shot_id}")
# 保存图像 laser_distance_m = None
save_shot_image( laser_signal_quality = 0
result_img,
center, # x,y 单位物理厘米compute_laser_position 与三角形单应性均输出物理 cm
radius, # 未检测到靶心时 x/y 用 200.0(脱靶标志)
method, srv_x = round(float(dx), 4) if dx is not None else 200.0
ellipse_params, srv_y = round(float(dy), 4) if dy is not None else 200.0
(x, y),
distance_m,
shot_id=shot_id,
photo_dir=config.PHOTO_DIR if config.SAVE_IMAGE_ENABLED else None
)
# 构造上报数据 # 构造上报数据
inner_data = { inner_data = {
"shot_id": shot_id, "shot_id": shot_id,
"x": float(dx) if dx is not None else 200.0, "x": srv_x,
"y": float(dy) if dy is not None else 200.0, "y": srv_y,
"r": 90.0, "r": 20.0, # 保留字段(服务端当前忽略,物理外环半径 cm
"d": round((distance_m or 0.0) * 100), "d": round((distance_m or 0.0) * 100),
"d_laser": 0.0, "d_laser": round((laser_distance_m or 0.0) * 100),
"d_laser_quality": 0, "d_laser_quality": laser_signal_quality,
"m": method if method else "no_target", "m": method if method else "no_target",
"adc": adc_val, "adc": adc_val,
"laser_method": laser_point_method, "laser_method": laser_point_method,
"target_x": float(x), "target_x": float(x),
"target_y": float(y), "target_y": float(y),
"offset_method": offset_method,
"distance_method": distance_method,
} }
if ellipse_params: if ellipse_params:
@@ -190,14 +304,99 @@ def process_shot(adc_val):
report_data = {"cmd": 1, "data": inner_data} report_data = {"cmd": 1, "data": inner_data}
network_manager.safe_enqueue(report_data, msg_type=2, high=True) network_manager.safe_enqueue(report_data, msg_type=2, high=True)
if logger: # 数据上报后再画标注,不干扰检测阶段的原始画面
if center and radius: if result_img is not None:
logger.info(f"射箭事件已加入发送队列已检测到靶心ID: {shot_id}") # 1. 若有三角形标记,先用 cv2 画轮廓 / 顶点 / ID再反推靶心位置
else: if tri_markers:
logger.info(f"射箭事件已加入发送队列未检测到靶心已保存图像ID: {shot_id}") import cv2 as _cv2
import numpy as _np
_img_cv = image.image2cv(result_img, False, False)
# 三角形轮廓 + 直角顶点 + ID
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)
# 靶心H_inv @ [0,0]):小红圆
_center_px = None
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) # 外框
_center_px = (_ocx, _ocy)
logger.info(f"[算法] 靶心: {_center_px}")
except Exception:
pass
# 叠加信息:落点-圆心距离 / 相机-靶距离等
try:
import math as _math
_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}")
if _lines:
_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,
)
except Exception:
pass
result_img = image.cv2image(_img_cv, False, False)
# 2. 激光十字线
_lc = image.Color(config.LASER_COLOR[0], config.LASER_COLOR[1], config.LASER_COLOR[2])
result_img.draw_line(int(x - config.LASER_LENGTH), int(y),
int(x + config.LASER_LENGTH), int(y),
_lc, config.LASER_THICKNESS)
result_img.draw_line(int(x), int(y - config.LASER_LENGTH),
int(x), int(y + config.LASER_LENGTH),
_lc, config.LASER_THICKNESS)
result_img.draw_circle(int(x), int(y), 1, _lc, config.LASER_THICKNESS)
# 闪一下激光(射箭反馈) # 闪一下激光(射箭反馈)
laser_manager.flash_laser(1000) if config.FLASH_LASER_WHILE_SHOOTING:
laser_manager.flash_laser(config.FLASH_LASER_DURATION_MS)
# 保存图像(异步队列,与 main.py 一致)
enqueue_save_shot(
result_img,
center,
radius,
method,
ellipse_params,
(x, y),
distance_m,
shot_id=shot_id,
photo_dir=config.PHOTO_DIR if config.SAVE_IMAGE_ENABLED else None,
)
if logger:
if dx is not None and dy is not None:
logger.info(f"射箭事件已加入发送队列(偏移=({dx:.2f},{dy:.2f})cmID: {shot_id}")
else:
logger.info(f"射箭事件已加入发送队列未检测到偏移已保存图像ID: {shot_id}")
time.sleep_ms(100) time.sleep_ms(100)
except Exception as e: except Exception as e:

36
test/test_camera_rtsp.py Normal file
View File

@@ -0,0 +1,36 @@
# from maix import time, rtsp, camera, image
# # 1. 初始化摄像头注意RTSP需要NV21格式
# # 分辨率可以根据需要调整,如 640x480 或 1280x720
# cam = camera.Camera(640, 480, image.Format.FMT_YVU420SP)
# # 2. 创建并启动RTSP服务器
# server = rtsp.Rtsp()
# server.bind_camera(cam)
# server.start()
# # 3. 打印出访问地址,例如: rtsp://192.168.xxx.xxx:8554/live
# print("RTSP 流地址:", server.get_url())
# # 4. 保持服务运行
# while True:
# time.sleep(1)
from maix import camera, time, app, http, image
# 初始化相机,注意格式要用 FMT_RGB888JPEG 编码需要 RGB 输入)
cam = camera.Camera(640, 480, image.Format.FMT_RGB888)
# 创建 JPEG 流服务器
stream = http.JpegStreamer()
stream.start()
print("RTSP 替代方案 - HTTP JPEG 流地址: http://{}:{}".format(stream.host(), stream.port()))
print("请在浏览器或 OpenCV 中访问: http://<MaixCAM_IP>:8000/stream")
while not app.need_exit():
img = cam.read()
jpg = img.to_jpeg() # 将 RGB 图像编码为 JPEG
stream.write(jpg) # 推送到 HTTP 客户端

6
triangle_positions.json Normal file
View File

@@ -0,0 +1,6 @@
{
"0": [-20.0, -20.0, 0.0],
"1": [-20.0, 20.0, 0.0],
"2": [ 20.0, 20.0, 0.0],
"3": [ 20.0, -20.0, 0.0]
}

645
triangle_target.py Normal file
View File

@@ -0,0 +1,645 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
靶纸四角等腰直角三角形检测、单应性落点、PnP 估距。
从 test/aruco_deteck.py 抽出,供主流程 shoot_manager 使用。
"""
import json
import os
from itertools import combinations
import cv2
import numpy as np
def _log(msg):
try:
from logger_manager import logger_manager
if logger_manager.logger:
logger_manager.logger.info(msg)
except Exception:
pass
def load_camera_from_xml(path):
"""读取 OpenCV FileStorage XML返回 (camera_matrix, dist_coeffs) 或 (None, None)。"""
if not path or not os.path.isfile(path):
_log(f"[TRI] 标定文件不存在: {path}")
return None, None
try:
fs = cv2.FileStorage(path, cv2.FILE_STORAGE_READ)
K = fs.getNode("camera_matrix").mat()
dist = fs.getNode("distortion_coefficients").mat()
fs.release()
if K is None or K.size == 0:
return None, None
if dist is None or dist.size == 0:
dist = np.zeros((5, 1), dtype=np.float64)
return K, dist
except Exception as e:
_log(f"[TRI] 读取标定失败: {e}")
return None, None
def load_triangle_positions(path):
"""加载 triangle_positions.json返回 dict[int, [x,y,z]]。"""
if not path or not os.path.isfile(path):
_log(f"[TRI] 三角形位置文件不存在: {path}")
return None
with open(path, "r", encoding="utf-8") as f:
raw = json.load(f)
return {int(k): v for k, v in raw.items()}
def homography_calibration(marker_centers, marker_ids, marker_positions, impact_point_pixel):
target_points = []
for mid in marker_ids:
pos = marker_positions.get(mid)
if pos is None:
return False, None, None, None
target_points.append([pos[0], pos[1]])
src_pts = np.array(marker_centers, dtype=np.float32)
dst_pts = np.array(target_points, dtype=np.float32)
H, _ = cv2.findHomography(src_pts, dst_pts, cv2.RANSAC, ransacReprojThreshold=1.0)
if H is None:
return False, None, None, None
pt = np.array([[impact_point_pixel]], dtype=np.float32)
transformed = cv2.perspectiveTransform(pt, H)
target_x = float(transformed[0][0][0])
target_y = float(transformed[0][0][1])
return True, target_x, target_y, H
def complete_fourth_point(detected_ids, detected_centers, marker_positions):
target_order = [0, 1, 2, 3]
target_coords = {mid: marker_positions[mid][:2] for mid in target_order}
all_ids = set(target_coords.keys())
missing_id = (all_ids - set(detected_ids)).pop()
known_src = []
known_dst = []
for mid, pt in zip(detected_ids, detected_centers):
known_src.append(pt)
known_dst.append(target_coords[mid])
M_inv, _ = cv2.estimateAffine2D(
np.array(known_dst, dtype=np.float32),
np.array(known_src, dtype=np.float32),
)
if M_inv is None:
return None
missing_target = target_coords[missing_id]
missing_src_h = M_inv @ np.array([missing_target[0], missing_target[1], 1.0])
missing_src = missing_src_h[:2]
complete_centers = []
for mid in target_order:
if mid == missing_id:
complete_centers.append(missing_src)
else:
idx = detected_ids.index(mid)
complete_centers.append(detected_centers[idx])
return complete_centers, target_order
def pnp_distance_meters(marker_ids, marker_centers_px, marker_positions, K, dist):
"""
靶面原点 (0,0,0) 到相机光心的距离:||tvec||object 单位为 cm 时 tvec 为 cm返回米。
"""
obj = []
for mid in marker_ids:
p = marker_positions[mid]
obj.append([float(p[0]), float(p[1]), float(p[2])])
obj_pts = np.array(obj, dtype=np.float64)
img_pts = np.array(marker_centers_px, dtype=np.float64)
ok, rvec, tvec = cv2.solvePnP(
obj_pts, img_pts, K, dist, flags=cv2.SOLVEPNP_ITERATIVE
)
if not ok:
return None
tvec = tvec.reshape(-1)
dist_cm = float(np.linalg.norm(tvec))
return dist_cm / 100.0
def detect_triangle_markers(
gray_image,
orig_gray=None,
size_range=(8, 500),
max_interior_gray=None,
dark_pixel_gray=None,
min_dark_ratio=None,
verbose=True,
):
# 读取可调参数(缺省值与 config.py 保持一致)
try:
import config as _cfg
early_exit = int(getattr(_cfg, "TRIANGLE_EARLY_EXIT_CANDIDATES", 4))
block_sizes = tuple(getattr(_cfg, "TRIANGLE_ADAPTIVE_BLOCK_SIZES", (11, 21, 35)))
max_combo_n = int(getattr(_cfg, "TRIANGLE_MAX_FILTERED_FOR_COMBO", 10))
if max_interior_gray is None:
max_interior_gray = int(getattr(_cfg, "TRIANGLE_MAX_INTERIOR_GRAY", 130))
if dark_pixel_gray is None:
dark_pixel_gray = int(getattr(_cfg, "TRIANGLE_DARK_PIXEL_GRAY", 130))
if min_dark_ratio is None:
min_dark_ratio = float(getattr(_cfg, "TRIANGLE_MIN_DARK_RATIO", 0.30))
min_contrast_diff = int(getattr(_cfg, "TRIANGLE_MIN_CONTRAST_DIFF", 15))
except Exception:
early_exit = 4
block_sizes = (11, 21, 35)
max_combo_n = 10
if max_interior_gray is None:
max_interior_gray = 130
if dark_pixel_gray is None:
dark_pixel_gray = 130
if min_dark_ratio is None:
min_dark_ratio = 0.30
min_contrast_diff = 15
min_leg, max_leg = size_range
min_area = 0.5 * (min_leg ** 2) * 0.1
kernel = cv2.getStructuringElement(cv2.MORPH_RECT, (3, 3))
def _check_shape(approx):
pts = approx.reshape(3, 2).astype(np.float32)
sides = [
np.linalg.norm(pts[1] - pts[0]),
np.linalg.norm(pts[2] - pts[1]),
np.linalg.norm(pts[0] - pts[2]),
]
order = sorted(range(3), key=lambda i: sides[i])
leg1, leg2, hyp = sides[order[0]], sides[order[1]], sides[order[2]]
avg_leg = (leg1 + leg2) / 2
if not (min_leg <= avg_leg <= max_leg):
return None
if abs(leg1 - leg2) / (avg_leg + 1e-6) > 0.20:
return None
if abs(hyp - avg_leg * np.sqrt(2)) / (avg_leg * np.sqrt(2) + 1e-6) > 0.20:
return None
edge_verts = [(0, 1), (1, 2), (2, 0)]
hv0, hv1 = edge_verts[order[2]]
right_v = 3 - hv0 - hv1
right_pt = pts[right_v]
v0 = pts[hv0] - right_pt
v1_vec = pts[hv1] - right_pt
cos_a = np.dot(v0, v1_vec) / (
np.linalg.norm(v0) * np.linalg.norm(v1_vec) + 1e-6
)
if abs(cos_a) > 0.20:
return None
return right_pt, avg_leg, pts
def _color_ok(approx):
if orig_gray is None:
return True
mask = np.zeros(orig_gray.shape[:2], dtype=np.uint8)
cv2.fillPoly(mask, [approx], 255)
erode_k = max(1, int(min(orig_gray.shape[:2]) * 0.002))
erode_k = min(erode_k, 5)
k = cv2.getStructuringElement(cv2.MORPH_ELLIPSE, (2 * erode_k + 1, 2 * erode_k + 1))
mask_in = cv2.erode(mask, k, iterations=1)
if cv2.countNonZero(mask_in) < 20:
mask_in = mask
mean_val = cv2.mean(orig_gray, mask=mask_in)[0]
ys, xs = np.where(mask_in > 0)
if len(xs) == 0:
return False
interior = orig_gray[ys, xs]
dark_ratio = float(np.mean(interior <= dark_pixel_gray))
# 条件1绝对阈值三角形内部足够暗
abs_ok = (mean_val <= max_interior_gray) and (dark_ratio >= min_dark_ratio)
# 条件2相对对比度 — 三角形内部比周围背景明显更暗
contrast_ok = False
if min_contrast_diff > 0:
try:
dilate_k = cv2.getStructuringElement(cv2.MORPH_ELLIPSE, (2 * erode_k + 3, 2 * erode_k + 3))
mask_dilated = cv2.dilate(mask, dilate_k, iterations=2)
mask_border = cv2.subtract(mask_dilated, mask)
border_nz = cv2.countNonZero(mask_border)
if border_nz > 20:
mean_surround = cv2.mean(orig_gray, mask=mask_border)[0]
contrast_ok = (mean_surround - mean_val) >= min_contrast_diff
except Exception:
pass
return abs_ok or contrast_ok
def _extract_candidates(binary_img):
contours, _ = cv2.findContours(binary_img, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE)
found = []
# ---- 诊断计数 ----
_n_area_skip = 0
_n_3vert = 0
_n_shape_ok = 0
_n_color_ok = 0
_dbg_fail_shape = [] # 记录前几个失败原因
_dbg_fail_color = [] # 记录前几个颜色失败详情
for cnt in contours:
area = cv2.contourArea(cnt)
if area < min_area:
_n_area_skip += 1
continue
peri = cv2.arcLength(cnt, True)
eps = 0.05 * peri if peri > 60 else 0.03 * peri
approx = cv2.approxPolyDP(cnt, eps, True)
if len(approx) != 3:
continue
_n_3vert += 1
shape = _check_shape(approx)
if shape is None:
if len(_dbg_fail_shape) < 3:
pts3 = approx.reshape(3, 2).astype(np.float32)
sides = sorted([np.linalg.norm(pts3[1]-pts3[0]),
np.linalg.norm(pts3[2]-pts3[1]),
np.linalg.norm(pts3[0]-pts3[2])])
avg_l = (sides[0]+sides[1])/2
reason = f"avg_leg={avg_l:.1f} range=[{min_leg},{max_leg}] legs={sides[0]:.1f},{sides[1]:.1f} hyp={sides[2]:.1f} exp_hyp={avg_l*1.414:.1f}"
_dbg_fail_shape.append(reason)
continue
_n_shape_ok += 1
if not _color_ok(approx):
if len(_dbg_fail_color) < 3 and orig_gray is not None:
mask = np.zeros(orig_gray.shape[:2], dtype=np.uint8)
cv2.fillPoly(mask, [approx], 255)
mean_v = cv2.mean(orig_gray, mask=mask)[0]
ys, xs = np.where(mask > 0)
if len(xs) > 0:
dr = float(np.mean(orig_gray[ys, xs] <= dark_pixel_gray))
else:
dr = 0
_dbg_fail_color.append(f"mean={mean_v:.1f}(<={max_interior_gray}?) dark_r={dr:.2f}(>={min_dark_ratio}?)")
continue
_n_color_ok += 1
right_pt, avg_leg, pts = shape
center_px = np.mean(pts, axis=0).tolist()
dedup_key = f"{int(center_px[0] // 10)},{int(center_px[1] // 10)}"
found.append({
"center_px": center_px,
"right_pt": right_pt.tolist(),
"corners": pts.tolist(),
"avg_leg": avg_leg,
"dedup_key": dedup_key,
})
if verbose:
_log(f"[TRI] _extract: total={len(contours)} area_skip={_n_area_skip} "
f"3vert={_n_3vert} shape_ok={_n_shape_ok} color_ok={_n_color_ok}")
if _dbg_fail_shape:
_log(f"[TRI] shape失败原因(前3): {'; '.join(_dbg_fail_shape)}")
if _dbg_fail_color:
_log(f"[TRI] color失败原因(前3): {'; '.join(_dbg_fail_color)}")
return found
all_candidates = []
seen_keys = set()
# 早退条件:不仅要数量够,还要候选分布足够分散(覆盖多个象限),避免误检集中导致提前退出
h0, w0 = gray_image.shape[:2]
cx0, cy0 = w0 / 2.0, h0 / 2.0
seen_quadrants = set()
# 4 个候选就够 4 角检测3 个够 3 点补全,加 1 裕量
_EARLY_EXIT = max(3, early_exit)
def _add_from_binary(b):
b = cv2.morphologyEx(b, cv2.MORPH_CLOSE, kernel)
for c in _extract_candidates(b):
if c["dedup_key"] not in seen_keys:
seen_keys.add(c["dedup_key"])
all_candidates.append(c)
# 象限统计:按图像中心划分
tx, ty = c["center_px"]
if tx < cx0 and ty < cy0:
q = 0
elif tx < cx0:
q = 1
elif ty >= cy0:
q = 2
else:
q = 3
seen_quadrants.add(q)
def _should_early_exit():
# 至少覆盖 3 个象限 + 数量达到阈值,才认为“足够像四角”可停止更多尝试
return (len(all_candidates) >= _EARLY_EXIT) and (len(seen_quadrants) >= 3)
# 1. 最快:全局 Otsu无需逐像素邻域计算~10ms
_, b_otsu = cv2.threshold(gray_image, 0, 255, cv2.THRESH_BINARY_INV + cv2.THRESH_OTSU)
# ---- 临时调试:保存 Otsu 二值图供人工检查 ----
try:
import config as _dbg_cfg
if getattr(_dbg_cfg, 'TRIANGLE_SAVE_DEBUG_IMAGE', False):
_dbg_path = getattr(_dbg_cfg, 'PHOTO_DIR', '/root/phot') + '/tri_otsu_debug.jpg'
cv2.imwrite(_dbg_path, b_otsu)
_log(f"[TRI] DEBUG: Otsu 二值图已保存到 {_dbg_path}")
except Exception:
pass
_add_from_binary(b_otsu)
# 2. 只在 Otsu 不够时才跑自适应阈值(每次 ~100ms尽早退出
for block_size in block_sizes:
if _should_early_exit():
break
if block_size is None:
continue
b = cv2.adaptiveThreshold(
gray_image, 255, cv2.ADAPTIVE_THRESH_GAUSSIAN_C, cv2.THRESH_BINARY_INV, block_size, 4
)
_add_from_binary(b)
if verbose:
_log(f"[TRI] 候选三角形共 {len(all_candidates)} 个(预过滤前)")
if len(all_candidates) < 2:
return []
all_legs = [c["avg_leg"] for c in all_candidates]
med_leg = float(np.median(all_legs))
filtered = []
for c in all_candidates:
leg = c["avg_leg"]
if med_leg > 1e-6 and not (0.40 * med_leg <= leg <= 2.0 * med_leg):
continue
filtered.append(c)
if len(filtered) < 2:
return []
# 候选过多时,四点组合枚举会变慢:截断到更可能的 max_combo_n 个候选
if max_combo_n > 0 and len(filtered) > max_combo_n:
# 以 avg_leg 接近中位数优先(更符合四角同尺度)
med_leg = float(np.median([c["avg_leg"] for c in filtered]))
filtered = sorted(filtered, key=lambda c: abs(c["avg_leg"] - med_leg))[:max_combo_n]
def _order_quad(pts_4):
by_y = sorted(range(4), key=lambda i: pts_4[i][1])
top_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]
def _score_quad(cands_4):
pts = [np.array(c["center_px"]) for c in cands_4]
legs = [c["avg_leg"] for c in cands_4]
tl, bl, br, tr = _order_quad(pts)
diag1 = np.linalg.norm(pts[tl] - pts[br])
diag2 = np.linalg.norm(pts[bl] - pts[tr])
diag_ratio = max(diag1, diag2) / (min(diag1, diag2) + 1e-6)
s_top = np.linalg.norm(pts[tl] - pts[tr])
s_bot = np.linalg.norm(pts[bl] - pts[br])
s_left = np.linalg.norm(pts[tl] - pts[bl])
s_right = np.linalg.norm(pts[tr] - pts[br])
h_ratio = max(s_top, s_bot) / (min(s_top, s_bot) + 1e-6)
v_ratio = max(s_left, s_right) / (min(s_left, s_right) + 1e-6)
med_l = float(np.median(legs))
leg_dev = max(abs(l - med_l) / (med_l + 1e-6) for l in legs)
score = (diag_ratio - 1.0) * 3.0 + (h_ratio - 1.0) + (v_ratio - 1.0) + leg_dev * 2.0
return score, (tl, bl, br, tr)
assigned = None
if len(filtered) >= 4:
best_score = float("inf")
best_combo = None
best_order = None
for combo in combinations(range(len(filtered)), 4):
cands = [filtered[i] for i in combo]
score, order = _score_quad(cands)
if score < best_score:
best_score = score
best_combo = combo
best_order = order
if verbose:
_log(f"[TRI] 最优四边形: score={best_score:.3f}")
if best_score < 3.0:
cands = [filtered[i] for i in best_combo]
tl, bl, br, tr = best_order
assigned = {
0: cands[tl],
1: cands[bl],
2: cands[br],
3: cands[tr],
}
if assigned is None:
cx = np.mean([c["center_px"][0] for c in filtered])
cy = np.mean([c["center_px"][1] for c in filtered])
quadrant_map = {}
for c in filtered:
tx, ty = c["center_px"]
if tx < cx and ty < cy:
q = 0
elif tx < cx:
q = 1
elif ty >= cy:
q = 2
else:
q = 3
if q not in quadrant_map or c["avg_leg"] > quadrant_map[q]["avg_leg"]:
quadrant_map[q] = c
assigned = quadrant_map
result = []
for tid in sorted(assigned.keys()):
c = assigned[tid]
result.append({
"id": tid,
"center": c["right_pt"],
"corners": c["corners"],
})
return result
def try_triangle_scoring(
img_rgb,
laser_xy,
marker_positions,
camera_matrix,
dist_coeffs,
size_range=(8, 500),
):
"""
尝试三角形单应性 + PnP 估距。
img_rgb: RGB与 laser_xy 同一像素坐标系。
返回 dict:
ok, dx_cm, dy_cm, distance_m, offset_method, distance_method
"""
out = {
"ok": False,
"dx_cm": None,
"dy_cm": None,
"distance_m": None,
"offset_method": None,
"distance_method": None,
}
if marker_positions is None or camera_matrix is None or dist_coeffs is None:
return out
h_orig, w_orig = img_rgb.shape[:2]
# 缩图加速:嵌入式 CPU 上图像处理耗时与面积成正比。
# 不再写死 320/640默认按相机最长边缩到 1/2由 config.TRIANGLE_DETECT_SCALE 控制)。
# 检测完后把像素坐标乘以 inv_scale 还原到原始分辨率,再送入单应性/PnP与 K 标定分辨率一致)
try:
import config as _cfg
scale = float(getattr(_cfg, "TRIANGLE_DETECT_SCALE", 0.5))
except Exception:
scale = 0.5
if not (0.05 <= scale <= 1.0):
scale = 0.5
MAX_DETECT_DIM = max(64, int(max(h_orig, w_orig) * scale))
long_side = max(h_orig, w_orig)
if long_side > MAX_DETECT_DIM:
det_scale = MAX_DETECT_DIM / long_side
det_w = int(w_orig * det_scale)
det_h = int(h_orig * det_scale)
img_det = cv2.resize(img_rgb, (det_w, det_h), interpolation=cv2.INTER_LINEAR)
inv_scale = 1.0 / det_scale
size_range_det = (max(4, int(size_range[0] * det_scale)),
max(8, int(size_range[1] * det_scale)))
else:
img_det = img_rgb
inv_scale = 1.0
size_range_det = size_range
gray = cv2.cvtColor(img_det, cv2.COLOR_RGB2GRAY)
# 快速路径:直接在原始灰度图上跑(内部先走 Otsu几乎不耗时
# 光照均匀时通常在这一步就找到 ≥3 个三角形,完全跳过 CLAHE
tri_markers = detect_triangle_markers(
gray, orig_gray=gray, size_range=size_range_det, verbose=True
)
if len(tri_markers) < 3:
# 慢速兜底CLAHE 增强对比度后再试(光线不均 / 局部过暗时有效)
# 默认关闭以优先速度;由 config.TRIANGLE_ENABLE_CLAHE_FALLBACK 控制。
try:
import config as _cfg
enable_clahe = bool(getattr(_cfg, "TRIANGLE_ENABLE_CLAHE_FALLBACK", False))
except Exception:
enable_clahe = False
if enable_clahe:
_log(f"[TRI] 快速路径不足{len(tri_markers)}启用CLAHE增强")
clahe = cv2.createCLAHE(clipLimit=2.0, tileGridSize=(8, 8))
gray_clahe = clahe.apply(gray)
tri_markers = detect_triangle_markers(
gray_clahe, orig_gray=gray, size_range=size_range_det, verbose=True
)
else:
_log(f"[TRI] 快速路径不足{len(tri_markers)}跳过CLAHE兜底已关闭")
if len(tri_markers) < 3:
_log(f"[TRI] 三角形不足3个: {len(tri_markers)}")
return out
# 将缩图坐标还原为原始分辨率K 矩阵在原始分辨率下标定)
if inv_scale != 1.0:
for m in tri_markers:
m["center"] = [m["center"][0] * inv_scale, m["center"][1] * inv_scale]
m["corners"] = [[c[0] * inv_scale, c[1] * inv_scale] for c in m["corners"]]
lx = float(np.clip(laser_xy[0], 0, w_orig - 1))
ly = float(np.clip(laser_xy[1], 0, h_orig - 1))
if len(tri_markers) == 4:
tri_sorted = sorted(tri_markers, key=lambda m: m["id"])
marker_ids = [m["id"] for m in tri_sorted]
marker_centers = [[float(m["center"][0]), float(m["center"][1])] for m in tri_sorted]
offset_tag = "triangle_homography"
else:
marker_ids_list = [m["id"] for m in tri_markers]
marker_centers_orig = [[float(m["center"][0]), float(m["center"][1])] for m in tri_markers]
comp = complete_fourth_point(marker_ids_list, marker_centers_orig, marker_positions)
if comp is None:
_log("[TRI] 3点补全第4点失败")
return out
marker_centers, marker_ids = comp
marker_centers = [[float(c[0]), float(c[1])] for c in marker_centers]
offset_tag = "triangle_homography_3pt"
# ---------- 结果有效性校验(防 nan/inf 与退化角点) ----------
try:
import config as _cfg
min_center_dist_px = float(getattr(_cfg, "TRIANGLE_MIN_CENTER_DIST_PX", 3.0))
max_dist_m = float(getattr(_cfg, "TRIANGLE_MAX_DISTANCE_M", 20.0))
except Exception:
min_center_dist_px = 3.0
max_dist_m = 20.0
def _all_finite(v) -> bool:
try:
return bool(np.all(np.isfinite(v)))
except Exception:
return False
# 1) 4 个角点中心不能退化/重复(两两距离要大于阈值)
try:
pts = np.array(marker_centers, dtype=np.float64).reshape(-1, 2)
ok_centers = True
for i in range(len(pts)):
for j in range(i + 1, len(pts)):
if float(np.linalg.norm(pts[i] - pts[j])) <= min_center_dist_px:
ok_centers = False
break
if not ok_centers:
break
if not ok_centers:
_log(f"[TRI] 角点退化/重复center_dist <= {min_center_dist_px:.1f}px判定三角形失败")
return out
except Exception:
# 校验异常时不信任结果,直接回退
_log("[TRI] 角点校验异常,判定三角形失败")
return out
ok_h, tx, ty, _H = homography_calibration(
marker_centers, marker_ids, marker_positions, [lx, ly]
)
if not ok_h:
_log("[TRI] 单应性失败")
return out
# 2) 单应性矩阵必须是有限数
if (not _all_finite(_H)):
_log("[TRI] 单应性出现 nan/inf判定三角形失败")
return out
# 3) dx/dy 必须是有限数
if (not _all_finite([tx, ty])):
_log("[TRI] 偏移出现 nan/inf判定三角形失败")
return out
# 与 laser_manager.compute_laser_position 现网约定一致:(x_cm, -y_cm_target)
out["dx_cm"] = tx
out["dy_cm"] = -ty
out["ok"] = True
out["offset_method"] = offset_tag
out["markers"] = tri_markers # 供上层绘制标注用
out["homography"] = _H # 供上层反推靶心像素位置用
dist_m = pnp_distance_meters(marker_ids, marker_centers, marker_positions, camera_matrix, dist_coeffs)
# 4) distance_m 若存在也必须是有限数且在合理范围(默认 <20m
if dist_m is not None and _all_finite([dist_m]) and 0.3 < dist_m < max_dist_m:
out["distance_m"] = dist_m
out["distance_method"] = "pnp_triangle"
_log(f"[TRI] PnP 距离={dist_m:.2f}m, 偏移=({out['dx_cm']:.2f},{out['dy_cm']:.2f})cm")
else:
out["distance_m"] = None
out["distance_method"] = None
_log(f"[TRI] PnP 距离无效,回退黄心估距; 偏移=({out['dx_cm']:.2f},{out['dy_cm']:.2f})cm")
return out

View File

@@ -4,7 +4,7 @@
应用版本号 应用版本号
每次 OTA 更新时,只需要更新这个文件中的版本号 每次 OTA 更新时,只需要更新这个文件中的版本号
""" """
VERSION = '1.2.10' VERSION = '1.2.11'
# 1.2.0 开始使用C++编译成.so替换部分代码 # 1.2.0 开始使用C++编译成.so替换部分代码
# 1.2.1 ota使用加密包 # 1.2.1 ota使用加密包
@@ -17,6 +17,7 @@ VERSION = '1.2.10'
# 1.2.8 1 加快 wifi 下数据传输的速度。2 调整射箭时处理的逻辑优先上报数据再存照片之类的操作。3假如是用户打开激光的射箭触发后不再关闭激光因为是调瞄阶段 # 1.2.8 1 加快 wifi 下数据传输的速度。2 调整射箭时处理的逻辑优先上报数据再存照片之类的操作。3假如是用户打开激光的射箭触发后不再关闭激光因为是调瞄阶段
# 1.2.9 增加电源板的控制和自动关机的功能 # 1.2.9 增加电源板的控制和自动关机的功能
# 1.2.10 config formal # 1.2.10 config formal
# 1.2.11 增加三角形的单应性算法,适配对应的靶纸

View File

@@ -217,7 +217,7 @@ def check_image_sharpness(frame, threshold=100.0, save_debug_images=False):
# 保存原始图像 # 保存原始图像
img_orig = image.cv2image(img_cv, False, False) img_orig = image.cv2image(img_cv, False, False)
orig_filename = f"{debug_dir}/sharpness_debug_orig_{img_count:04d}.bmp" orig_filename = f"{debug_dir}/sharpness_debug_orig_{img_count:04d}.jpg"
img_orig.save(orig_filename) img_orig.save(orig_filename)
# # 保存边缘检测结果(可视化) # # 保存边缘检测结果(可视化)
@@ -294,7 +294,7 @@ def save_calibration_image(frame, laser_pos, photo_dir=None):
img_count = 0 img_count = 0
x, y = laser_pos x, y = laser_pos
filename = f"{photo_dir}/calibration_{int(x)}_{int(y)}_{img_count:04d}.bmp" filename = f"{photo_dir}/calibration_{int(x)}_{int(y)}_{img_count:04d}.jpg"
logger = logger_manager.logger logger = logger_manager.logger
if logger: if logger:
@@ -334,190 +334,348 @@ def save_calibration_image(frame, laser_pos, photo_dir=None):
logger.error(traceback.format_exc()) logger.error(traceback.format_exc())
return None return None
def detect_circle_v3(frame, laser_point=None): # def detect_circle_v3(frame, laser_point=None):
# """检测图像中的靶心(优先清晰轮廓,其次黄色区域)- 返回椭圆参数版本
# 增加红色圆圈检测,验证黄色圆圈是否为真正的靶心
# 如果提供 laser_point会选择最接近激光点的目标
# Args:
# frame: 图像帧
# laser_point: 激光点坐标 (x, y),用于多目标场景下的目标选择
# Returns:
# (result_img, best_center, best_radius, method, best_radius1, ellipse_params)
# """
# img_cv = image.image2cv(frame, False, False)
# best_center = best_radius = best_radius1 = method = None
# ellipse_params = None
# # HSV 黄色掩码检测(模糊靶心)
# hsv = cv2.cvtColor(img_cv, cv2.COLOR_RGB2HSV)
# h, s, v = cv2.split(hsv)
# # 调整饱和度策略:稍微增强,不要过度
# s = np.clip(s * 1.1, 0, 255).astype(np.uint8)
# hsv = cv2.merge((h, s, v))
# # 放宽 HSV 阈值范围(针对模糊图像的关键调整)
# lower_yellow = np.array([7, 80, 0]) # 饱和度下限降低,捕捉淡黄色
# upper_yellow = np.array([32, 255, 255]) # 亮度上限拉满
# mask_yellow = cv2.inRange(hsv, lower_yellow, upper_yellow)
# # 调整形态学操作
# kernel = cv2.getStructuringElement(cv2.MORPH_ELLIPSE, (5, 5))
# mask_yellow = cv2.morphologyEx(mask_yellow, cv2.MORPH_CLOSE, kernel)
# contours_yellow, _ = cv2.findContours(mask_yellow, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE)
# # 存储所有有效的黄色-红色组合
# valid_targets = []
# if contours_yellow:
# for cnt_yellow in contours_yellow:
# area = cv2.contourArea(cnt_yellow)
# perimeter = cv2.arcLength(cnt_yellow, True)
# # 计算圆度
# if perimeter > 0:
# circularity = (4 * np.pi * area) / (perimeter * perimeter)
# else:
# circularity = 0
# logger = logger_manager.logger
# if area > 50 and circularity > 0.7:
# if logger:
# logger.info(f"[target] -> 面积:{area}, 圆度:{circularity:.2f}")
# # 尝试拟合椭圆
# yellow_center = None
# yellow_radius = None
# yellow_ellipse = None
# if len(cnt_yellow) >= 5:
# (x, y), (width, height), angle = cv2.fitEllipse(cnt_yellow)
# yellow_ellipse = ((x, y), (width, height), angle)
# axes_minor = min(width, height)
# radius = axes_minor / 2
# yellow_center = (int(x), int(y))
# yellow_radius = int(radius)
# else:
# (x, y), radius = cv2.minEnclosingCircle(cnt_yellow)
# yellow_center = (int(x), int(y))
# yellow_radius = int(radius)
# yellow_ellipse = None
# # 如果检测到黄色圆圈,再检测红色圆圈进行验证
# if yellow_center and yellow_radius:
# # HSV 红色掩码检测红色在HSV中跨越0度需要两个范围
# # 红色范围1: 0-10度接近0度的红色
# lower_red1 = np.array([0, 80, 0])
# upper_red1 = np.array([10, 255, 255])
# mask_red1 = cv2.inRange(hsv, lower_red1, upper_red1)
# # 红色范围2: 170-180度接近180度的红色
# lower_red2 = np.array([170, 80, 0])
# upper_red2 = np.array([180, 255, 255])
# mask_red2 = cv2.inRange(hsv, lower_red2, upper_red2)
# # 合并两个红色掩码
# mask_red = cv2.bitwise_or(mask_red1, mask_red2)
# # 形态学操作
# kernel_red = cv2.getStructuringElement(cv2.MORPH_ELLIPSE, (5, 5))
# mask_red = cv2.morphologyEx(mask_red, cv2.MORPH_CLOSE, kernel_red)
# contours_red, _ = cv2.findContours(mask_red, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE)
# found_valid_red = False
# if contours_red:
# # 找到所有符合条件的红色圆圈
# for cnt_red in contours_red:
# area_red = cv2.contourArea(cnt_red)
# perimeter_red = cv2.arcLength(cnt_red, True)
# if perimeter_red > 0:
# circularity_red = (4 * np.pi * area_red) / (perimeter_red * perimeter_red)
# else:
# circularity_red = 0
# # 红色圆圈也应该有一定的圆度
# if area_red > 50 and circularity_red > 0.6:
# # 计算红色圆圈的中心和半径
# if len(cnt_red) >= 5:
# (x_red, y_red), (w_red, h_red), angle_red = cv2.fitEllipse(cnt_red)
# radius_red = min(w_red, h_red) / 2
# red_center = (int(x_red), int(y_red))
# red_radius = int(radius_red)
# else:
# (x_red, y_red), radius_red = cv2.minEnclosingCircle(cnt_red)
# red_center = (int(x_red), int(y_red))
# red_radius = int(radius_red)
# # 计算黄色和红色圆心的距离
# if red_center:
# dx = yellow_center[0] - red_center[0]
# dy = yellow_center[1] - red_center[1]
# distance = np.sqrt(dx*dx + dy*dy)
# # 圆心距离阈值应该小于黄色半径的某个倍数比如1.5倍)
# max_distance = yellow_radius * 1.5
# # 红色圆圈应该比黄色圆圈大(外圈)
# if distance < max_distance and red_radius > yellow_radius * 0.8:
# found_valid_red = True
# logger = logger_manager.logger
# if logger:
# logger.info(f"[target] -> 找到匹配的红圈: 黄心({yellow_center}), 红心({red_center}), 距离:{distance:.1f}, 黄半径:{yellow_radius}, 红半径:{red_radius}")
# # 记录这个有效目标
# valid_targets.append({
# 'center': yellow_center,
# 'radius': yellow_radius,
# 'ellipse': yellow_ellipse,
# 'area': area
# })
# break
# if not found_valid_red:
# logger = logger_manager.logger
# if logger:
# logger.debug("Debug -> 未找到匹配的红色圆圈,可能是误识别")
# # 从所有有效目标中选择最佳目标
# if valid_targets:
# if laser_point:
# # 如果有激光点,选择最接近激光点的目标
# best_target = None
# min_distance = float('inf')
# for target in valid_targets:
# dx = target['center'][0] - laser_point[0]
# dy = target['center'][1] - laser_point[1]
# distance = np.sqrt(dx*dx + dy*dy)
# if distance < min_distance:
# min_distance = distance
# best_target = target
# if best_target:
# best_center = best_target['center']
# best_radius = best_target['radius']
# ellipse_params = best_target['ellipse']
# method = "v3_ellipse_red_validated_laser_selected"
# best_radius1 = best_radius * 5
# else:
# # 如果没有激光点,选择面积最大的目标
# best_target = max(valid_targets, key=lambda t: t['area'])
# best_center = best_target['center']
# best_radius = best_target['radius']
# ellipse_params = best_target['ellipse']
# method = "v3_ellipse_red_validated"
# best_radius1 = best_radius * 5
# result_img = image.cv2image(img_cv, False, False)
# return result_img, best_center, best_radius, method, best_radius1, ellipse_params
def detect_circle_v3(frame, laser_point=None, img_cv=None):
"""检测图像中的靶心(优先清晰轮廓,其次黄色区域)- 返回椭圆参数版本 """检测图像中的靶心(优先清晰轮廓,其次黄色区域)- 返回椭圆参数版本
增加红色圆圈检测验证黄色圆圈是否为真正的靶心 增加红色圆圈检测验证黄色圆圈是否为真正的靶心
如果提供 laser_point会选择最接近激光点的目标 如果提供 laser_point会选择最接近激光点的目标
优化
1. 缩图到 MAX_DET_DIM 后再做 HSV/形态学最长边 640->320 可获得 ~4x 加速
2. 红色掩码在黄色轮廓循环外只计算一次避免 N 次重复计算
3. img_cv 可由外部传入与其他线程共享转换结果 None 时自动转换
Args: Args:
frame: 图像帧 frame: 图像帧img_cv None 时使用
laser_point: 激光点坐标 (x, y)用于多目标场景下的目标选择 laser_point: 激光点坐标 (x, y)用于多目标场景下的目标选择
img_cv: 已转换的 numpy BGR/RGB 图像不为 None 时跳过 image2cv 转换
Returns: Returns:
(result_img, best_center, best_radius, method, best_radius1, ellipse_params) (result_img, best_center, best_radius, method, best_radius1, ellipse_params)
""" """
img_cv = image.image2cv(frame, False, False) if img_cv is None:
img_cv = image.image2cv(frame, False, False)
logger = logger_manager.logger
from datetime import datetime
logger.debug(f"[detect_circle_v3] begin {datetime.now()}")
# -- 1. 缩图加速(与三角形路径保持一致)
h_orig, w_orig = img_cv.shape[:2]
MAX_DET_DIM = 320
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 best_center = best_radius = best_radius1 = method = None
ellipse_params = None ellipse_params = None
# HSV 黄色掩码检测(模糊靶心) logger.debug(f"[detect_circle_v3] step 1 fin {datetime.now()}")
hsv = cv2.cvtColor(img_cv, cv2.COLOR_RGB2HSV)
# -- 2. HSV + 黄色掩码
hsv = cv2.cvtColor(img_det, cv2.COLOR_RGB2HSV)
h, s, v = cv2.split(hsv) h, s, v = cv2.split(hsv)
# 调整饱和度策略:稍微增强,不要过度
s = np.clip(s * 1.1, 0, 255).astype(np.uint8) s = np.clip(s * 1.1, 0, 255).astype(np.uint8)
hsv = cv2.merge((h, s, v)) hsv = cv2.merge((h, s, v))
lower_yellow = np.array([7, 80, 0])
# 放宽 HSV 阈值范围(针对模糊图像的关键调整) upper_yellow = np.array([32, 255, 255])
lower_yellow = np.array([7, 80, 0]) # 饱和度下限降低,捕捉淡黄色
upper_yellow = np.array([32, 255, 255]) # 亮度上限拉满
mask_yellow = cv2.inRange(hsv, lower_yellow, upper_yellow) mask_yellow = cv2.inRange(hsv, lower_yellow, upper_yellow)
# 调整形态学操作
kernel = cv2.getStructuringElement(cv2.MORPH_ELLIPSE, (5, 5)) kernel = cv2.getStructuringElement(cv2.MORPH_ELLIPSE, (5, 5))
mask_yellow = cv2.morphologyEx(mask_yellow, cv2.MORPH_CLOSE, kernel) mask_yellow = cv2.morphologyEx(mask_yellow, cv2.MORPH_CLOSE, kernel)
contours_yellow, _ = cv2.findContours(mask_yellow, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE) logger.debug(f"[detect_circle_v3] step 2 fin {datetime.now()}")
# 存储所有有效的黄色-红色组合 # -- 3. 红色掩码:在循环外只算一次
valid_targets = [] mask_red = cv2.bitwise_or(
cv2.inRange(hsv, np.array([0, 80, 0]), np.array([10, 255, 255])),
if contours_yellow: cv2.inRange(hsv, np.array([170, 80, 0]), np.array([180, 255, 255])),
for cnt_yellow in contours_yellow: )
area = cv2.contourArea(cnt_yellow) kernel_red = cv2.getStructuringElement(cv2.MORPH_ELLIPSE, (5, 5))
perimeter = cv2.arcLength(cnt_yellow, True) 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) 列表,后续直接查表
if perimeter > 0: red_candidates = []
circularity = (4 * np.pi * area) / (perimeter * perimeter) for cnt_r in contours_red:
else: ar = cv2.contourArea(cnt_r)
circularity = 0 if ar <= 50:
continue
logger = logger_manager.logger pr = cv2.arcLength(cnt_r, True)
if area > 50 and circularity > 0.7: if pr <= 0 or (4 * np.pi * ar) / (pr * pr) <= 0.6:
if logger: continue
logger.info(f"[target] -> 面积:{area}, 圆度:{circularity:.2f}") if len(cnt_r) >= 5:
# 尝试拟合椭圆 (xr, yr), (wr, hr), _ = cv2.fitEllipse(cnt_r)
yellow_center = None red_candidates.append({"center": (int(xr), int(yr)), "radius": int(min(wr, hr) / 2)})
yellow_radius = None
yellow_ellipse = None
if len(cnt_yellow) >= 5:
(x, y), (width, height), angle = cv2.fitEllipse(cnt_yellow)
yellow_ellipse = ((x, y), (width, height), angle)
axes_minor = min(width, height)
radius = axes_minor / 2
yellow_center = (int(x), int(y))
yellow_radius = int(radius)
else:
(x, y), radius = cv2.minEnclosingCircle(cnt_yellow)
yellow_center = (int(x), int(y))
yellow_radius = int(radius)
yellow_ellipse = None
# 如果检测到黄色圆圈,再检测红色圆圈进行验证
if yellow_center and yellow_radius:
# HSV 红色掩码检测红色在HSV中跨越0度需要两个范围
# 红色范围1: 0-10度接近0度的红色
lower_red1 = np.array([0, 80, 0])
upper_red1 = np.array([10, 255, 255])
mask_red1 = cv2.inRange(hsv, lower_red1, upper_red1)
# 红色范围2: 170-180度接近180度的红色
lower_red2 = np.array([170, 80, 0])
upper_red2 = np.array([180, 255, 255])
mask_red2 = cv2.inRange(hsv, lower_red2, upper_red2)
# 合并两个红色掩码
mask_red = cv2.bitwise_or(mask_red1, mask_red2)
# 形态学操作
kernel_red = cv2.getStructuringElement(cv2.MORPH_ELLIPSE, (5, 5))
mask_red = cv2.morphologyEx(mask_red, cv2.MORPH_CLOSE, kernel_red)
contours_red, _ = cv2.findContours(mask_red, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE)
found_valid_red = False
if contours_red:
# 找到所有符合条件的红色圆圈
for cnt_red in contours_red:
area_red = cv2.contourArea(cnt_red)
perimeter_red = cv2.arcLength(cnt_red, True)
if perimeter_red > 0:
circularity_red = (4 * np.pi * area_red) / (perimeter_red * perimeter_red)
else:
circularity_red = 0
# 红色圆圈也应该有一定的圆度
if area_red > 50 and circularity_red > 0.6:
# 计算红色圆圈的中心和半径
if len(cnt_red) >= 5:
(x_red, y_red), (w_red, h_red), angle_red = cv2.fitEllipse(cnt_red)
radius_red = min(w_red, h_red) / 2
red_center = (int(x_red), int(y_red))
red_radius = int(radius_red)
else:
(x_red, y_red), radius_red = cv2.minEnclosingCircle(cnt_red)
red_center = (int(x_red), int(y_red))
red_radius = int(radius_red)
# 计算黄色和红色圆心的距离
if red_center:
dx = yellow_center[0] - red_center[0]
dy = yellow_center[1] - red_center[1]
distance = np.sqrt(dx*dx + dy*dy)
# 圆心距离阈值应该小于黄色半径的某个倍数比如1.5倍)
max_distance = yellow_radius * 1.5
# 红色圆圈应该比黄色圆圈大(外圈)
if distance < max_distance and red_radius > yellow_radius * 0.8:
found_valid_red = True
logger = logger_manager.logger
if logger:
logger.info(f"[target] -> 找到匹配的红圈: 黄心({yellow_center}), 红心({red_center}), 距离:{distance:.1f}, 黄半径:{yellow_radius}, 红半径:{red_radius}")
# 记录这个有效目标
valid_targets.append({
'center': yellow_center,
'radius': yellow_radius,
'ellipse': yellow_ellipse,
'area': area
})
break
if not found_valid_red:
logger = logger_manager.logger
if logger:
logger.debug("Debug -> 未找到匹配的红色圆圈,可能是误识别")
# 从所有有效目标中选择最佳目标
if valid_targets:
if laser_point:
# 如果有激光点,选择最接近激光点的目标
best_target = None
min_distance = float('inf')
for target in valid_targets:
dx = target['center'][0] - laser_point[0]
dy = target['center'][1] - laser_point[1]
distance = np.sqrt(dx*dx + dy*dy)
if distance < min_distance:
min_distance = distance
best_target = target
if best_target:
best_center = best_target['center']
best_radius = best_target['radius']
ellipse_params = best_target['ellipse']
method = "v3_ellipse_red_validated_laser_selected"
best_radius1 = best_radius * 5
else: else:
# 如果没有激光点,选择面积最大的目标 (xr, yr), rr = cv2.minEnclosingCircle(cnt_r)
best_target = max(valid_targets, key=lambda t: t['area']) red_candidates.append({"center": (int(xr), int(yr)), "radius": int(rr)})
best_center = best_target['center']
best_radius = best_target['radius'] logger.debug(f"[detect_circle_v3] step 3 fin {datetime.now()}")
ellipse_params = best_target['ellipse']
# -- 4. 黄色轮廓循环(复用上面的红色候选列表)
contours_yellow, _ = cv2.findContours(mask_yellow, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE)
valid_targets = []
for cnt_yellow in contours_yellow:
area = cv2.contourArea(cnt_yellow)
if area <= 50:
continue
perimeter = cv2.arcLength(cnt_yellow, True)
if perimeter <= 0:
continue
circularity = (4 * np.pi * area) / (perimeter * perimeter)
if circularity <= 0.7:
continue
if logger:
logger.info(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.8:
if logger:
logger.info(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 and logger:
logger.debug("Debug -> 未找到匹配的红色圆圈,可能是误识别")
logger.debug(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" method = "v3_ellipse_red_validated"
best_radius1 = best_radius * 5 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) result_img = image.cv2image(img_cv, False, False)
logger.debug(f"[detect_circle_v3] step 5 fin {datetime.now()}")
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):
"""根据像素半径估算实际距离(单位:米)""" """根据像素半径估算实际距离(单位:米)"""
if not pixel_radius: if not pixel_radius:
@@ -560,11 +718,13 @@ def _save_shot_image_impl(img_cv, center, radius, method, ellipse_params,
x, y = laser_point x, y = laser_point
if shot_id: if shot_id:
if center is None or radius is None: # 之前是用 center/radius 判定 no_target但三角形路径会返回 center=None正常
filename = f"{photo_dir}/shot_{shot_id}_no_target.bmp" # 这里改为:只要 method 有值,就按 method 命名;否则才回退 no_target
method_str = (method or "").strip()
if method_str:
filename = f"{photo_dir}/shot_{shot_id}_{method_str}.jpg"
else: else:
method_str = method or "unknown" filename = f"{photo_dir}/shot_{shot_id}_no_target.jpg"
filename = f"{photo_dir}/shot_{shot_id}_{method_str}.bmp"
else: else:
try: try:
all_images = [f for f in os.listdir(photo_dir) if f.endswith(('.bmp', '.jpg', '.jpeg'))] all_images = [f for f in os.listdir(photo_dir) if f.endswith(('.bmp', '.jpg', '.jpeg'))]
@@ -577,7 +737,7 @@ def _save_shot_image_impl(img_cv, center, radius, method, ellipse_params,
else: else:
method_str = method or "unknown" method_str = method or "unknown"
distance_str = str(round((distance_m or 0.0) * 100)) distance_str = str(round((distance_m or 0.0) * 100))
filename = f"{photo_dir}/{method_str}_{int(x)}_{int(y)}_{distance_str}_{img_count:04d}.bmp" filename = f"{photo_dir}/{method_str}_{int(x)}_{int(y)}_{distance_str}_{img_count:04d}.jpg"
logger = logger_manager.logger logger = logger_manager.logger
if logger: if logger:
@@ -591,16 +751,16 @@ def _save_shot_image_impl(img_cv, center, radius, method, ellipse_params,
else: else:
logger.info(f"结果 -> 未检测到靶心,保存原始图像(激光点: ({x}, {y})") logger.info(f"结果 -> 未检测到靶心,保存原始图像(激光点: ({x}, {y})")
laser_color = (config.LASER_COLOR[0], config.LASER_COLOR[1], config.LASER_COLOR[2]) # laser_color = (config.LASER_COLOR[0], config.LASER_COLOR[1], config.LASER_COLOR[2])
cross_thickness = int(max(getattr(config, "LASER_THICKNESS", 1), 1)) # cross_thickness = int(max(getattr(config, "LASER_THICKNESS", 1), 1))
cross_length = int(max(getattr(config, "LASER_LENGTH", 10), 10)) # cross_length = int(max(getattr(config, "LASER_LENGTH", 10), 10))
cv2.line(img_cv, (int(x - cross_length), int(y)), (int(x + cross_length), int(y)), laser_color, cross_thickness) # cv2.line(img_cv, (int(x - cross_length), int(y)), (int(x + cross_length), int(y)), laser_color, cross_thickness)
cv2.line(img_cv, (int(x), int(y - cross_length)), (int(x), int(y + cross_length)), laser_color, cross_thickness) # cv2.line(img_cv, (int(x), int(y - cross_length)), (int(x), int(y + cross_length)), laser_color, cross_thickness)
cv2.circle(img_cv, (int(x), int(y)), 1, laser_color, cross_thickness) # cv2.circle(img_cv, (int(x), int(y)), 1, laser_color, cross_thickness)
ring_thickness = 1 # ring_thickness = 1
cv2.circle(img_cv, (int(x), int(y)), 10, laser_color, ring_thickness) # cv2.circle(img_cv, (int(x), int(y)), 10, laser_color, ring_thickness)
cv2.circle(img_cv, (int(x), int(y)), 5, laser_color, ring_thickness) # cv2.circle(img_cv, (int(x), int(y)), 5, laser_color, ring_thickness)
cv2.circle(img_cv, (int(x), int(y)), 2, laser_color, -1) # cv2.circle(img_cv, (int(x), int(y)), 2, laser_color, -1)
if center and radius: if center and radius:
cx, cy = center cx, cy = center

80
wifi.py
View File

@@ -116,8 +116,28 @@ class WiFiManager:
# ==================== WiFi 连接方法 ==================== # ==================== WiFi 连接方法 ====================
def is_sta_associated(self):
"""
是否作为 STA 已关联到上游 AP用于与 AP 模式区分AP 模式下 wlan0 可能有 IP 但 iw link 为 Not connected
"""
try:
out = os.popen("iw dev wlan0 link 2>/dev/null").read()
if not out.strip():
return False
if "Not connected" in out:
return False
return "Connected to" in out
except Exception:
return False
def is_wifi_connected(self): def is_wifi_connected(self):
"""检查WiFi是否已连接""" """检查WiFi是否已连接"""
# AP 模式下 wlan0 也可能有 IP如 192.168.66.1),但这不代表已作为 STA 连上路由器。
# 业务侧(选网/TCP只应在 STA 已关联到上游 AP 时认为 WiFi 可用。
if not self.is_sta_associated():
self._wifi_connected = False
return False
# 优先用 MaixPy network如果可用 # 优先用 MaixPy network如果可用
try: try:
from maix import network from maix import network
@@ -272,6 +292,66 @@ class WiFiManager:
self.logger.error(f"[WIFI] 连接/验证失败,已回滚: {e}") self.logger.error(f"[WIFI] 连接/验证失败,已回滚: {e}")
return None, str(e) return None, str(e)
def persist_sta_credentials(self, ssid: str, password: str, restart_service: bool = True):
"""
仅写入 STA 凭证(/etc/wpa_supplicant.conf + /boot/wifi.ssid|pass
可选是否立即 /etc/init.d/S30wifi restart。
不做可达性验证。用于热点配网页提交后切换到连接指定路由器。
password 为空时按开放网络key_mgmt=NONE写入。
Returns:
(ok: bool, err_msg: str)
"""
ssid = (ssid or "").strip()
password = (password or "").strip()
if not ssid:
return False, "SSID 为空"
conf_path = "/etc/wpa_supplicant.conf"
ssid_file = "/boot/wifi.ssid"
pass_file = "/boot/wifi.pass"
def _write_text(path: str, content: str):
with open(path, "w", encoding="utf-8") as f:
f.write(content)
try:
if password:
net_conf = os.popen(f'wpa_passphrase "{ssid}" "{password}"').read()
if "network={" not in net_conf:
return False, "wpa_passphrase 失败"
else:
esc = ssid.replace("\\", "\\\\").replace('"', '\\"')
net_conf = (
"network={\n"
f' ssid="{esc}"\n'
" key_mgmt=NONE\n"
"}\n"
)
_write_text(
conf_path,
"ctrl_interface=/var/run/wpa_supplicant\n"
"update_config=1\n\n"
+ net_conf,
)
except Exception as e:
return False, str(e)
try:
_write_text(ssid_file, ssid)
_write_text(pass_file, password)
except Exception as e:
return False, str(e)
if restart_service:
try:
os.system("/etc/init.d/S30wifi restart")
except Exception as e:
return False, str(e)
self.logger.info(f"[WIFI] persist_sta_credentials: 已写入并重启 S30wifi, ssid={ssid!r}")
else:
self.logger.info(f"[WIFI] persist_sta_credentials: 已写入凭证(未重启 S30wifi, ssid={ssid!r}")
return True, ""
def disconnect_wifi(self): def disconnect_wifi(self):
"""断开WiFi连接并清理资源""" """断开WiFi连接并清理资源"""
if self._wifi_socket: if self._wifi_socket:

521
wifi_config_httpd.py Normal file
View File

@@ -0,0 +1,521 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
WiFi 热点配网:迷你 HTTP 服务器(仅 GET/POST标准库 socket独立线程运行。
策略(与 /etc/init.d/S30wifi 一致):
- 仅当 STA 未连上 WiFi 且 4G 也不可用时,写入 /boot/wifi.ap、去掉 /boot/wifi.sta
并重启 S30wifi 由系统起热点;再在本进程起 HTTP。
- 用户 POST 提交路由器 SSID/密码后仅写凭证、stop S30wifi、删 /boot/wifi.ap、建 /boot/wifi.sta、sync、reboot。
"""
import html
import os
import socket
import threading
import time as std_time
from urllib.parse import parse_qs
import config
from logger_manager import logger_manager
from wifi import wifi_manager
_http_thread = None
_http_stop = threading.Event()
def _http_response(status, body_bytes, content_type="text/html; charset=utf-8"):
head = (
f"HTTP/1.1 {status}\r\n"
f"Content-Type: {content_type}\r\n"
f"Content-Length: {len(body_bytes)}\r\n"
f"Connection: close\r\n"
f"\r\n"
).encode("utf-8")
return head + body_bytes
def _read_http_request(conn, max_total=65536):
"""返回 (method, path, headers_str, body_bytes) 或 None。"""
buf = b""
while b"\r\n\r\n" not in buf and len(buf) < max_total:
chunk = conn.recv(4096)
if not chunk:
break
buf += chunk
if b"\r\n\r\n" not in buf:
return None
idx = buf.index(b"\r\n\r\n")
header_bytes = buf[:idx]
rest = buf[idx + 4 :]
try:
headers_str = header_bytes.decode("utf-8", errors="replace")
except Exception:
headers_str = ""
lines = headers_str.split("\r\n")
if not lines:
return None
parts = lines[0].split()
method = parts[0] if parts else "GET"
path = parts[1] if len(parts) > 1 else "/"
content_length = 0
for line in lines[1:]:
if line.lower().startswith("content-length:"):
try:
content_length = int(line.split(":", 1)[1].strip())
except Exception:
content_length = 0
break
body = rest
while content_length > 0 and len(body) < content_length and len(body) < max_total:
chunk = conn.recv(4096)
if not chunk:
break
body += chunk
body = body[:content_length]
return method, path, headers_str, body
def _page_form(msg_html=""):
# 页面展示的热点名:以 /boot/wifi.ssid 为准(与实际 AP 保持一致)
try:
if os.path.exists("/boot/wifi.ssid"):
with open("/boot/wifi.ssid", "r", encoding="utf-8") as f:
_ssid = f.read().strip()
else:
_ssid = ""
except Exception:
_ssid = ""
ap_ssid = html.escape(_ssid or getattr(config, "WIFI_CONFIG_AP_SSID", "ArcherySetup"))
port = int(getattr(config, "WIFI_CONFIG_HTTP_PORT", 8080))
ap_ip = html.escape(getattr(config, "WIFI_CONFIG_AP_IP", "192.168.66.1"))
body = f"""<!DOCTYPE html>
<html><head><meta charset="utf-8"/><meta name="viewport" content="width=device-width,initial-scale=1"/>
<title>WiFi 配网</title></head><body>
<h1>WiFi 配网</h1>
<p>热点:<b>{ap_ssid}</b> · 端口 <b>{port}</b></p>
<p>请填写要连接的<b>路由器</b> SSID 与密码(用于 STA 上网,不是热点密码)。提交后将关闭热点、保存并<b>重启设备</b>。</p>
{msg_html}
<form method="POST" action="/" accept-charset="utf-8">
<p>SSID<br/><input name="ssid" type="text" style="width:100%;max-width:320px" required/></p>
<p>密码(开放网络可留空)<br/><input name="password" type="password" style="width:100%;max-width:320px"/></p>
<p><button type="submit">保存并重启</button></p>
</form>
<p style="color:#666;font-size:12px">提示:提交后设备会重启;请手机改连路由器 WiFi。</p>
</body></html>"""
return body.encode("utf-8")
def _apply_sta_and_reboot(router_ssid: str, router_password: str):
"""
写路由器 STA 凭证 -> 停 WiFi 服务 -> 删 /boot/wifi.ap -> 建 /boot/wifi.sta -> sync -> reboot
"""
logger = logger_manager.logger
ok, err = wifi_manager.persist_sta_credentials(router_ssid, router_password, restart_service=False)
if not ok:
return False, err
try:
os.system("/etc/init.d/S30wifi stop")
except Exception as e:
logger.warning(f"[WIFI-AP] S30wifi stop: {e}")
ap_flag = "/boot/wifi.ap"
sta_flag = "/boot/wifi.sta"
try:
if os.path.exists(ap_flag):
os.remove(ap_flag)
except Exception as e:
return False, f"删除 {ap_flag} 失败: {e}"
try:
with open(sta_flag, "w", encoding="utf-8") as f:
f.write("")
except Exception as e:
return False, f"创建 {sta_flag} 失败: {e}"
try:
os.system("sync")
except Exception:
pass
logger.info("[WIFI-AP] 已切换为 STA 标志并准备 reboot")
try:
os.system("reboot")
except Exception as e:
return False, f"reboot 调用失败: {e}"
return True, ""
def _handle_client(conn, addr):
logger = logger_manager.logger
try:
conn.settimeout(30.0)
req = _read_http_request(conn)
if not req:
conn.sendall(_http_response("400 Bad Request", b"Bad Request"))
return
method, path, _headers, body = req
path = path.split("?", 1)[0]
if method == "GET" and path in ("/", "/index.html"):
conn.sendall(_http_response("200 OK", _page_form()))
return
if method == "POST" and path in ("/", "/index.html"):
try:
qs = body.decode("utf-8", errors="replace")
except Exception:
qs = ""
fields = parse_qs(qs, keep_blank_values=True)
ssid = (fields.get("ssid") or [""])[0].strip()
password = (fields.get("password") or [""])[0]
ok, err = _apply_sta_and_reboot(ssid, password)
if ok:
msg = '<p style="color:green"><b>已保存,设备正在重启…</b></p>'
else:
msg = f'<p style="color:red"><b>失败:</b>{html.escape(err)}</p>'
conn.sendall(_http_response("200 OK", _page_form(msg)))
return
if method == "GET" and path == "/favicon.ico":
conn.sendall(_http_response("204 No Content", b""))
return
conn.sendall(_http_response("404 Not Found", b"Not Found"))
except Exception as e:
try:
logger.error(f"[WIFI-HTTP] 处理请求异常 {addr}: {e}")
except Exception:
pass
finally:
try:
conn.close()
except Exception:
pass
def _serve_loop(host, port):
logger = logger_manager.logger
srv = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
try:
srv.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
srv.bind((host, port))
srv.listen(5)
srv.settimeout(1.0)
logger.info(f"[WIFI-HTTP] 监听 {host}:{port}")
except Exception as e:
logger.error(f"[WIFI-HTTP] bind 失败: {e}")
try:
srv.close()
except Exception:
pass
return
while not _http_stop.is_set():
try:
conn, addr = srv.accept()
except socket.timeout:
continue
except Exception as e:
if _http_stop.is_set():
break
logger.warning(f"[WIFI-HTTP] accept: {e}")
continue
t = threading.Thread(target=_handle_client, args=(conn, addr), daemon=True)
t.start()
try:
srv.close()
except Exception:
pass
logger.info("[WIFI-HTTP] 服务已停止")
def _ensure_hostapd_ssid(ssid: str, logger=None) -> bool:
"""
某些固件会把 SSID 写到 /etc/hostapd.conf 或 /boot/hostapd.conf。
为避免只改 /boot/wifi.ssid 不生效,这里同步更新已存在的 hostapd.conf。
Returns:
bool: 任一文件被修改则 True
"""
if logger is None:
logger = logger_manager.logger
if not ssid:
return False
changed_any = False
for conf_path in ("/etc/hostapd.conf", "/boot/hostapd.conf"):
try:
if not os.path.exists(conf_path):
continue
with open(conf_path, "r", encoding="utf-8") as f:
lines = f.read().splitlines()
except Exception:
continue
changed = False
out = []
seen = False
for ln in lines:
s = ln.strip()
if s.lower().startswith("ssid="):
seen = True
cur = s.split("=", 1)[1].strip()
if cur != ssid:
out.append(f"ssid={ssid}")
changed = True
else:
out.append(ln)
else:
out.append(ln)
if not seen:
out.append(f"ssid={ssid}")
changed = True
if changed:
try:
with open(conf_path, "w", encoding="utf-8") as f:
f.write("\n".join(out).rstrip() + "\n")
changed_any = True
except Exception as e:
if logger:
logger.warning(f"[WIFI-AP] 写入 {conf_path} 失败: {e}")
if changed_any and logger:
logger.info(f"[WIFI-AP] 已同步热点 SSID 到 hostapd.conf: {ssid}")
return changed_any
def _write_boot_ap_credentials_for_s30wifi():
"""供 S30wifi AP 分支 gen_hostapd 使用的热点 SSID/密码。"""
base = (getattr(config, "WIFI_CONFIG_AP_SSID", "ArcherySetup") or "ArcherySetup").strip()
# 追加设备码,便于区分多台设备(读取 /device_key失败则不加后缀
suffix = ""
try:
with open("/device_key", "r", encoding="utf-8") as f:
dev = (f.read() or "").strip()
if dev:
s = dev
# 只保留字母数字,避免 SSID 出现不可见字符
s = "".join([c for c in s if c.isalnum()])
if s:
suffix = s
except Exception:
suffix = ""
ssid = f"{base}_{suffix}" if suffix else base
pwd = getattr(config, "WIFI_CONFIG_AP_PASSWORD", "12345678")
with open("/boot/wifi.ssid", "w", encoding="utf-8") as f:
f.write(ssid.strip())
with open("/boot/wifi.pass", "w", encoding="utf-8") as f:
f.write(pwd.strip())
try:
_ensure_hostapd_ssid(ssid.strip())
except Exception:
pass
def _ensure_hostapd_modern_security(logger=None) -> bool:
"""
确保 AP 使用较新的安全标准(至少 WPA2-PSK + CCMP
你现场验证需要的两行:
- wpa_key_mgmt=WPA-PSK
- rsn_pairwise=CCMP
Returns:
bool: 若文件被修改返回 True否则 False
"""
if logger is None:
logger = logger_manager.logger
conf_path = "/etc/hostapd.conf"
try:
if not os.path.exists(conf_path):
return False
with open(conf_path, "r", encoding="utf-8") as f:
lines = f.read().splitlines()
except Exception as e:
logger.warning(f"[WIFI-AP] 读取 hostapd.conf 失败: {e}")
return False
wanted = {
"wpa_key_mgmt": "WPA-PSK",
"rsn_pairwise": "CCMP",
}
changed = False
seen = set()
new_lines = []
for ln in lines:
s = ln.strip()
if not s or s.startswith("#") or "=" not in s:
new_lines.append(ln)
continue
k, v = s.split("=", 1)
k = k.strip()
if k in wanted:
seen.add(k)
new_v = wanted[k]
if v.strip() != new_v:
new_lines.append(f"{k}={new_v}")
changed = True
else:
new_lines.append(ln)
continue
new_lines.append(ln)
# 缺的补到末尾
for k, v in wanted.items():
if k not in seen:
new_lines.append(f"{k}={v}")
changed = True
if not changed:
return False
try:
with open(conf_path, "w", encoding="utf-8") as f:
f.write("\n".join(new_lines).rstrip() + "\n")
logger.info("[WIFI-AP] 已更新 /etc/hostapd.conf 安全参数WPA-PSK + CCMP")
return True
except Exception as e:
logger.warning(f"[WIFI-AP] 写入 hostapd.conf 失败: {e}")
return False
def _cleanup_ap_flag_if_needed(logger):
"""若 /boot/wifi.ap 残留,删除它并恢复 /boot/wifi.sta避免 main.py 误判为 AP 配网模式。"""
ap_flag = "/boot/wifi.ap"
sta_flag = "/boot/wifi.sta"
if not os.path.exists(ap_flag):
return
try:
os.remove(ap_flag)
logger.info(f"[WIFI-AP] 已清理残留标记 {ap_flag}")
except Exception as e:
logger.warning(f"[WIFI-AP] 清理 {ap_flag} 失败: {e}")
return
if not os.path.exists(sta_flag):
try:
with open(sta_flag, "w", encoding="utf-8") as f:
f.write("")
logger.info(f"[WIFI-AP] 已恢复 {sta_flag}")
except Exception as e:
logger.warning(f"[WIFI-AP] 恢复 {sta_flag} 失败: {e}")
def _switch_boot_to_ap_mode(logger):
"""
去掉 STA 标志、建立 AP 标志,由 S30wifi 起 hostapd与 Maix start_ap 二选一,以系统脚本为准)。
"""
try:
sta = "/boot/wifi.sta"
ap = "/boot/wifi.ap"
if os.path.exists(sta):
os.remove(sta)
with open(ap, "w", encoding="utf-8") as f:
f.write("")
os.system("/etc/init.d/S30wifi restart")
# 某些固件生成的 hostapd.conf 缺少新安全参数,导致 Windows 提示“较旧的安全标准”。
# 若本次修改了 hostapd.conf则再重启一次让 hostapd 重新加载配置。
try:
if _ensure_hostapd_modern_security(logger):
os.system("/etc/init.d/S30wifi restart")
except Exception:
pass
return True
except Exception as e:
logger.error(f"[WIFI-AP] 切换 /boot 为 AP 模式失败: {e}")
return False
def start_http_server_thread():
"""仅启动 HTTP 线程(假定 AP 已由 S30wifi 拉起)。"""
global _http_thread
logger = logger_manager.logger
if _http_thread is not None and _http_thread.is_alive():
logger.warning("[WIFI-HTTP] 配网线程已在运行")
return
_http_stop.clear()
host = getattr(config, "WIFI_CONFIG_HTTP_HOST", "0.0.0.0")
port = int(getattr(config, "WIFI_CONFIG_HTTP_PORT", 8080))
_http_thread = threading.Thread(
target=_serve_loop,
args=(host, port),
daemon=True,
name="wifi_config_httpd",
)
_http_thread.start()
def maybe_start_wifi_ap_fallback(logger=None):
"""
若启用 WIFI_CONFIG_AP_FALLBACK等待若干秒后检测 STA WiFi 与 4G
仅当二者均不可用时,写热点用的 /boot/wifi.ssid|pass、切到 /boot/wifi.ap 并 restart S30wifi再启动 HTTP。
"""
if logger is None:
logger = logger_manager.logger
if not getattr(config, "WIFI_CONFIG_AP_FALLBACK", False):
return
from network import network_manager
# 先快速检测一次:若 STA 或 4G 已可用,直接返回,避免不必要的等待
wifi_ok = wifi_manager.is_sta_associated()
g4_ok = network_manager.is_4g_available()
logger.info(f"[WIFI-AP] 兜底检测(quick)sta关联={wifi_ok}, 4g={g4_ok}")
if wifi_ok or g4_ok:
logger.info("[WIFI-AP] STA 或 4G 可用,不启动热点配网")
# 清理上次开机可能残留的 /boot/wifi.ap 标记,避免 main.py 误判为 AP 配网模式
_cleanup_ap_flag_if_needed(logger)
return
# 两者均不可用:再按配置等待一段时间后复检,避免开机瞬态误判
wait_sec = int(getattr(config, "WIFI_AP_FALLBACK_WAIT_SEC", 10))
wait_sec = max(0, min(wait_sec, 120))
if wait_sec > 0:
logger.info(f"[WIFI-AP] 兜底配网:等待 {wait_sec}s 后再检测 STA/4G…")
std_time.sleep(wait_sec)
# 必须用 STA 关联判断is_wifi_connected() 在 AP 模式会因 192.168.66.1 误判为已连接
wifi_ok = wifi_manager.is_sta_associated()
g4_ok = network_manager.is_4g_available()
logger.info(f"[WIFI-AP] 兜底检测sta关联={wifi_ok}, 4g={g4_ok}")
if wifi_ok or g4_ok:
logger.info("[WIFI-AP] STA 或 4G 可用,不启动热点配网")
_cleanup_ap_flag_if_needed(logger)
return
logger.warning("[WIFI-AP] STA 与 4G 均不可用,启动热点配网(/boot/wifi.ap + HTTP")
try:
_write_boot_ap_credentials_for_s30wifi()
except Exception as e:
logger.error(f"[WIFI-AP] 写热点 /boot 凭证失败: {e}")
return
if not _switch_boot_to_ap_mode(logger):
return
std_time.sleep(3)
start_http_server_thread()
p = int(getattr(config, "WIFI_CONFIG_HTTP_PORT", 8080))
ip = getattr(config, "WIFI_CONFIG_AP_IP", "192.168.66.1")
logger.info(f"[WIFI-AP] 请连接热点后访问 http://{ip}:{p}/ (若 IP 以 S30wifi 为准)")
def stop_wifi_config_http():
"""请求停止 HTTP 线程(下次 accept 超时后退出)。"""
_http_stop.set()
# 兼容旧名:不再使用「强制开 AP」逻辑统一走 maybe_start_wifi_ap_fallback
def start_wifi_config_ap_thread():
maybe_start_wifi_ap_fallback()