Compare commits
7 Commits
685dce2519
...
dev
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1bace88f37 | ||
|
|
ba5ca7e0b3 | ||
|
|
e030f3a194 | ||
|
|
43e7e0ba17 | ||
|
|
0ee970d8bd | ||
|
|
ead2060ab3 | ||
|
|
bdc3254ed2 |
11
app.yaml
11
app.yaml
@@ -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
|
||||||
|
|||||||
BIN
archery_netcore.cpython-311-riscv64-linux-gnu.so
Normal file
BIN
archery_netcore.cpython-311-riscv64-linux-gnu.so
Normal file
Binary file not shown.
33
cameraParameters.xml
Normal file
33
cameraParameters.xml
Normal 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>
|
||||||
59
config.py
59
config.py
@@ -24,18 +24,29 @@ 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 纳入综合判定(False 则仅看 RTT)
|
||||||
|
|
||||||
|
# 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 = "/maixapp/apps/t11/server.pem" # 设备文件系统里 CA 证书路径(你自己放进去)
|
||||||
# MIPOPEN 末尾的参数在不同固件里含义可能不同;按你手册例子保留
|
# MIPOPEN 末尾的参数在不同固件里含义可能不同;按你手册例子保留
|
||||||
MIPOPEN_TAIL = ",,0"
|
MIPOPEN_TAIL = ",,0"
|
||||||
|
|
||||||
@@ -84,7 +95,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 +122,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 +169,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
|
||||||
|
|||||||
@@ -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的自己的文件系统中。
|
||||||
|
1)maixcam-2025-12-31-maixpy-v4.12.4.img.xz
|
||||||
|
2)maixcam_builtin_files.tar.xz
|
||||||
|
3)MaixPy-4.12.4-py3-none-any.whl
|
||||||
|
4)Source code(zip)
|
||||||
|
5.2. 把自己的文件放到 buildtin_files中:
|
||||||
|
1)我把项目文件目录 t11 放到了 maixcam_builtin_files\maixapp\apps 这个目录下。
|
||||||
|
2)为了能让它自启动,我把 auto_start.txt 放到了 maixcam_builtin_files\maixapp 这个目录下。
|
||||||
|
|
||||||
|
5.3. 然后在解压后的源码中找到tools/os目录下 /home/saga/maixcam/MaixPy-4.12.4/tools/os/maixcam
|
||||||
|
执行
|
||||||
|
export MAIXCDK_PATH=/home/saga/maixcam/MaixCDK
|
||||||
|
编译:
|
||||||
|
./gen_os.sh ../../../../../maixcam/maixcam-2025-12-31-maixpy-v4.12.4.img ../../../../../maixcam/MaixPy-4.12.4-py3-none-any.whl ../../../../../maixcam/maixcam_builtin_files 0 maixcam
|
||||||
|
注意,在编译过程中,也会去 github 下载内容,所以需要打开梯子。
|
||||||
|
5.4. 等待编译完成,会编译成镜像文件,然后根据 https://wiki.sipeed.com/hardware/zh/maixcam/os.html 这个指引来烧录系统。
|
||||||
|
5.5. 烧录完系统后,需要安装 runtime, 可以按照 https://wiki.sipeed.com/maixpy/doc/zh/README_no_screen.html 这个来升级运行库,或者直接在 Maixvision 中链接的时候安装 runtime。
|
||||||
|
5.6. 安装 runtime 之后,重启,我们的系统就会自己启动起来了。
|
||||||
|
|
||||||
|
遇到问题:
|
||||||
|
/mnt/d/code/shooting/compile_maixcam/MaixPy-4.12.4/MaixPy-4.12.4/tools/os/maixcam/fuse2fs: error while loading shared libraries: libfuse.so.2: cannot open shared object file: No such file or directory
|
||||||
|
解决办法:
|
||||||
|
安装 libfuse2
|
||||||
|
sudo apt update
|
||||||
|
sudo apt install libfuse2
|
||||||
|
|
||||||
|
遇到问题:
|
||||||
|
python 缺少 yaml
|
||||||
|
解决办法:
|
||||||
|
pip install pyyaml
|
||||||
|
|
||||||
|
遇到问题:
|
||||||
|
./build_all.sh: line 56: maixtool: command not found
|
||||||
|
解决办法:
|
||||||
|
export PATH="/mnt/d/code/MaixCDK/.venv/bin:$PATH"
|
||||||
|
|
||||||
|
遇到问题:
|
||||||
|
./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
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|
||||||
|
|||||||
@@ -103,3 +103,122 @@ 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_m(PnP)
|
||||||
|
兜底路径:圆/椭圆靶心 → dx,dy(基于黄心半径比例/透视校正) + distance_m(黄心半径估距)
|
||||||
|
|
||||||
|
12. 4g模块上传文件:
|
||||||
|
|
||||||
|
Upload images from MaixCam to Qiniu cloud via ML307R 4G module's AT commands. The HTTP body requires multipart/form-data with real CR/LF bytes (0x0D 0x0A) in boundaries.
|
||||||
|
Methods Tried
|
||||||
|
# Method AT Commands Result Root Cause
|
||||||
|
1 Raw binary, no encoding MHTTPCONTENT with raw bytes + length param ERROR at first chunk CR/LF in binary data terminates AT command parser
|
||||||
|
2 Encoding mode 2 (escape) MHTTPCFG="encoding",0,2 + \r\n escapes Server 400 Bad Request Module sends literal text \r\n to server, NOT actual 0x0D 0x0A bytes. Multipart body is garbled
|
||||||
|
3 Encoding mode 1 (hex) MHTTPCFG="encoding",0,1 + hex-encoded data CME ERROR: 650/50 Firmware doesn't properly support hex mode for MHTTPCONTENT
|
||||||
|
4 No chunked mode Skip MHTTPCFG="chunked" CME ERROR: 65 Module requires chunked mode to accept MHTTPCONTENT at all
|
||||||
|
5 Single large MHTTPCONTENT All data in one command (2793 bytes) +MHTTPURC: "err",0,5 (timeout) Possible buffer limit; module hangs then times out
|
||||||
|
6 Per-chunk HTTP instance (OTA style) CREATE→POST→DELETE per chunk Not feasible Each instance = separate HTTP request; Qiniu needs complete body in single POST
|
||||||
|
Conclusion: AT HTTP layer (MHTTPCONTENT) is fundamentally broken for binary uploads.
|
||||||
|
The Solution: Raw TCP Socket (MIPOPEN + MIPSEND)
|
||||||
|
Bypass the AT HTTP layer entirely. Open a raw TCP connection and send a hand-crafted HTTP POST:
|
||||||
|
plaintext
|
||||||
|
AT+MIPCLOSE=3 // Clean up old socket
|
||||||
|
AT+MIPOPEN=3,"TCP","upload.qiniup.com",80 // Raw TCP connection
|
||||||
|
AT+MIPSEND=3,1024 → ">" → [raw bytes] → OK // Binary-safe!
|
||||||
|
AT+MIPSEND=3,1024 → ">" → [raw bytes] → OK
|
||||||
|
AT+MIPSEND=3,766 → ">" → [raw bytes] → OK
|
||||||
|
// Response: +MIPURC: "rtcp",3,<len>,HTTP/1.1 200 OK...
|
||||||
|
AT+MIPCLOSE=3
|
||||||
|
Why it works:
|
||||||
|
MIPSEND enters prompt mode (>) — after the >, the AT parser treats ALL bytes as data, including CR/LF
|
||||||
|
We construct the complete HTTP request ourselves (headers + Content-Length + multipart body) with real CRLF bytes
|
||||||
|
|
||||||
|
Key bug found during integration: _send_chunk() wrapped calls in self.at._cmd_lock, but self.at.send() also acquires the same lock internally — threading.Lock() is not reentrant, causing deadlock. Fixed by removing the outer lock (the network_manager.get_uart_lock() already provides thread safety).Trade-off: UART is locked during the entire upload, so heartbeats pause. For small JPEG files (~2-80KB), this is 5-20 seconds — acceptable if server heartbeat timeout is generous
|
||||||
@@ -24,8 +24,8 @@ 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.pass(network.py/wifi.py 都有)。拿到设备存储就能读到明文密码,这属于设备侧安全问题。
|
|
||||||
2.3 日志/调试信息泄露
|
2.3 日志/调试信息泄露
|
||||||
你仓库里 .cursor/debug.log* 已经记录了完整的 AT+MIPOPEN ... host:port 等信息;如果这些被打进包或被用户导出,也会泄露通信细节。
|
你仓库里 .cursor/debug.log* 已经记录了完整的 AT+MIPOPEN ... host:port 等信息;如果这些被打进包或被用户导出,也会泄露通信细节。
|
||||||
你发布包里“真正会被带走的敏感内容”
|
你发布包里“真正会被带走的敏感内容”
|
||||||
@@ -37,5 +37,9 @@ files: - app.yaml - at_client.py - camera_manager.py - config.py - hardware
|
|||||||
把“password=device_id+.” 改为真正的 per-device secret(设备出厂烧录,服务端保存;或服务端下发公钥,设备用私钥签名)
|
把“password=device_id+.” 改为真正的 per-device secret(设备出厂烧录,服务端保存;或服务端下发公钥,设备用私钥签名)
|
||||||
所有上报/控制命令加签名 + nonce/timestamp + 服务端防重放(别人抓到一次包也不能复用)
|
所有上报/控制命令加签名 + nonce/timestamp + 服务端防重放(别人抓到一次包也不能复用)
|
||||||
OTA 包必须做签名校验(设备端内置公钥,下载后验签通过才应用)
|
OTA 包必须做签名校验(设备端内置公钥,下载后验签通过才应用)
|
||||||
TLS 必须做证书校验/最好做 pinning(至少别用 auth=0)
|
|
||||||
如果你告诉我:你们服务端目前能不能改协议(例如新增签名字段、下发 challenge、做 OTA 签名),我可以按“最小改动但提升最大安全”的顺序,帮你规划一套从现状平滑升级的方案。
|
如果你告诉我:你们服务端目前能不能改协议(例如新增签名字段、下发 challenge、做 OTA 签名),我可以按“最小改动但提升最大安全”的顺序,帮你规划一套从现状平滑升级的方案。
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
https://wiki.sipeed.com/maixpy/doc/zh/pro/compile_os.html
|
||||||
171
main.py
171
main.py
@@ -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():
|
||||||
@@ -100,6 +101,7 @@ def cmd_str():
|
|||||||
|
|
||||||
# 4. 初始化显示和相机
|
# 4. 初始化显示和相机
|
||||||
camera_manager.init_camera(640, 480)
|
camera_manager.init_camera(640, 480)
|
||||||
|
# camera_manager.init_camera(1280,720)
|
||||||
camera_manager.init_display()
|
camera_manager.init_display()
|
||||||
|
|
||||||
# ==================== 第二阶段:软件初始化 ====================
|
# ==================== 第二阶段:软件初始化 ====================
|
||||||
@@ -115,9 +117,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 +221,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 +356,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:
|
||||||
|
|||||||
874
network.py
874
network.py
@@ -25,6 +25,39 @@ from logger_manager import logger_manager
|
|||||||
from wifi import wifi_manager
|
from wifi import wifi_manager
|
||||||
|
|
||||||
|
|
||||||
|
def _calculate_tcp_ssl_password(device_id, iccid):
|
||||||
|
"""
|
||||||
|
与服务器 calculatePassword(deviceId, iccid) 一致:
|
||||||
|
hex(md5(hex(md5(deviceId)) + iccid)),iccid 为空则不拼接。
|
||||||
|
"""
|
||||||
|
md5_device_hex = hashlib.md5(device_id.encode("utf-8")).hexdigest()
|
||||||
|
if iccid:
|
||||||
|
md5_device_hex = md5_device_hex + iccid
|
||||||
|
return hashlib.md5(md5_device_hex.encode("utf-8")).hexdigest()
|
||||||
|
|
||||||
|
|
||||||
|
def _wifi_tls_would_block(exc):
|
||||||
|
"""
|
||||||
|
非阻塞 TLS 下 recv/send 常抛出 WANT_READ / WANT_WRITE(或等价文案),
|
||||||
|
表示需等待对端/内核缓冲区,不是断线。
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
import ssl as _ssl
|
||||||
|
except ImportError:
|
||||||
|
_ssl = None
|
||||||
|
if _ssl is not None and isinstance(exc, _ssl.SSLError):
|
||||||
|
err = getattr(exc, "errno", None)
|
||||||
|
if err in (
|
||||||
|
getattr(_ssl, "SSL_ERROR_WANT_READ", 2),
|
||||||
|
getattr(_ssl, "SSL_ERROR_WANT_WRITE", 3),
|
||||||
|
):
|
||||||
|
return True
|
||||||
|
msg = str(exc).lower()
|
||||||
|
if "did not complete" in msg and ("read" in msg or "write" in msg):
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
class NetworkManager:
|
class NetworkManager:
|
||||||
"""网络通信管理器(单例)"""
|
"""网络通信管理器(单例)"""
|
||||||
_instance = None
|
_instance = None
|
||||||
@@ -181,6 +214,16 @@ class NetworkManager:
|
|||||||
|
|
||||||
def read_device_id(self):
|
def read_device_id(self):
|
||||||
"""从 /device_key 文件读取设备唯一 ID,失败则使用默认值"""
|
"""从 /device_key 文件读取设备唯一 ID,失败则使用默认值"""
|
||||||
|
def _set_password_for_device_id(device_id):
|
||||||
|
if getattr(config, "USE_TCP_SSL", False):
|
||||||
|
iccid = self.get_4g_mccid()
|
||||||
|
iccid = iccid if iccid else ""
|
||||||
|
print(f"iccid: {iccid}")
|
||||||
|
self._password = _calculate_tcp_ssl_password(device_id, iccid)
|
||||||
|
else:
|
||||||
|
self._password = device_id + "."
|
||||||
|
print(f"self._password: {self._password}")
|
||||||
|
|
||||||
try:
|
try:
|
||||||
with open("/device_key", "r") as f:
|
with open("/device_key", "r") as f:
|
||||||
device_id = f.read().strip()
|
device_id = f.read().strip()
|
||||||
@@ -188,7 +231,7 @@ class NetworkManager:
|
|||||||
self.logger.debug(f"[INFO] 从 /device_key 读取到 DEVICE_ID: {device_id}")
|
self.logger.debug(f"[INFO] 从 /device_key 读取到 DEVICE_ID: {device_id}")
|
||||||
# 设置内部状态
|
# 设置内部状态
|
||||||
self._device_id = device_id
|
self._device_id = device_id
|
||||||
self._password = device_id + "."
|
_set_password_for_device_id(device_id)
|
||||||
return device_id
|
return device_id
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
self.logger.error(f"[ERROR] 无法读取 /device_key: {e}")
|
self.logger.error(f"[ERROR] 无法读取 /device_key: {e}")
|
||||||
@@ -196,7 +239,7 @@ class NetworkManager:
|
|||||||
# 使用默认值
|
# 使用默认值
|
||||||
default_id = "DEFAULT_DEVICE_ID"
|
default_id = "DEFAULT_DEVICE_ID"
|
||||||
self._device_id = default_id
|
self._device_id = default_id
|
||||||
self._password = default_id + "."
|
_set_password_for_device_id(default_id)
|
||||||
return default_id
|
return default_id
|
||||||
|
|
||||||
# ==================== WiFi 管理方法(委托给 wifi_manager)====================
|
# ==================== WiFi 管理方法(委托给 wifi_manager)====================
|
||||||
@@ -410,6 +453,82 @@ class NetworkManager:
|
|||||||
except Exception:
|
except Exception:
|
||||||
return False
|
return False
|
||||||
|
|
||||||
|
def get_4g_phone_number(self):
|
||||||
|
"""
|
||||||
|
读取 SIM 本机号码(AT+CNUM)。
|
||||||
|
典型响应:+CNUM: "","+861442093407954",145
|
||||||
|
部分运营商/卡未在卡内写入号码时可能为空。
|
||||||
|
Returns:
|
||||||
|
str: 号码(含国家码,如 +86138...),失败或未写入时返回 None
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
atc = hardware_manager.at_client
|
||||||
|
if atc is None:
|
||||||
|
return None
|
||||||
|
with self.get_uart_lock():
|
||||||
|
resp = atc.send("AT+CNUM", "OK", 3000)
|
||||||
|
if not resp:
|
||||||
|
return None
|
||||||
|
# 可能多行 +CNUM,取第一个非空号码
|
||||||
|
for m in re.finditer(r'\+CNUM:\s*"[^"]*"\s*,\s*"([^"]*)"', resp):
|
||||||
|
num = (m.group(1) or "").strip()
|
||||||
|
if num:
|
||||||
|
return num
|
||||||
|
return None
|
||||||
|
except Exception:
|
||||||
|
return None
|
||||||
|
|
||||||
|
def get_4g_mccid(self):
|
||||||
|
"""
|
||||||
|
读取 MCCID(AT+MCCID,模组侧命令,常用于 SIM/卡标识类信息)。
|
||||||
|
典型响应行:+MCCID: <值> 或 +MCCID: \"...\"
|
||||||
|
Returns:
|
||||||
|
str: 解析到的字符串;失败时返回 None
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
atc = hardware_manager.at_client
|
||||||
|
if atc is None:
|
||||||
|
return None
|
||||||
|
with self.get_uart_lock():
|
||||||
|
resp = atc.send("AT+MCCID", "OK", 3000)
|
||||||
|
if not resp or "ERROR" in resp.upper():
|
||||||
|
return None
|
||||||
|
m = re.search(r"\+MCCID:\s*(.+)", resp, re.IGNORECASE)
|
||||||
|
if not m:
|
||||||
|
return None
|
||||||
|
val = (m.group(1) or "").strip()
|
||||||
|
# 去掉行尾 OK 之前可能粘在一起的杂质:只取第一行有效内容
|
||||||
|
val = val.split("\r")[0].split("\n")[0].strip()
|
||||||
|
val = val.strip('"').strip()
|
||||||
|
return val if val else None
|
||||||
|
except Exception:
|
||||||
|
return None
|
||||||
|
|
||||||
|
def _maybe_add_iccid_to_login(self, login_data):
|
||||||
|
"""
|
||||||
|
若应用目录下尚无 iccid 标记文件,则在登录包中增加 iccid 字段(值为当前卡号)。
|
||||||
|
标记文件仅在「本次登录携带了 iccid 且服务器返回登录成功」后创建,见 _create_iccid_marker_file。
|
||||||
|
Returns:
|
||||||
|
bool: 本次登录是否携带了 iccid(即成功后需要创建标记文件)
|
||||||
|
"""
|
||||||
|
marker_path = os.path.join(config.APP_DIR, "iccid")
|
||||||
|
if os.path.exists(marker_path):
|
||||||
|
return False
|
||||||
|
iccid_val = self.get_4g_mccid()
|
||||||
|
login_data["iccid"] = iccid_val if iccid_val is not None else ""
|
||||||
|
return True
|
||||||
|
|
||||||
|
def _create_iccid_marker_file(self):
|
||||||
|
"""登录成功且曾携带 iccid 后创建空标记文件,后续登录不再带 iccid。"""
|
||||||
|
marker_path = os.path.join(config.APP_DIR, "iccid")
|
||||||
|
if os.path.exists(marker_path):
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
with open(marker_path, "w"):
|
||||||
|
pass
|
||||||
|
except Exception as e:
|
||||||
|
self.logger.warning(f"[NET] 创建 iccid 标记文件失败: {e}")
|
||||||
|
|
||||||
def _apply_session_force_4g(self):
|
def _apply_session_force_4g(self):
|
||||||
"""锁定本次会话为 4G(直到关机,期间不再回切 WiFi)"""
|
"""锁定本次会话为 4G(直到关机,期间不再回切 WiFi)"""
|
||||||
self._session_force_4g = True
|
self._session_force_4g = True
|
||||||
@@ -602,25 +721,78 @@ class NetworkManager:
|
|||||||
return self._connect_tcp_via_4g()
|
return self._connect_tcp_via_4g()
|
||||||
return False
|
return False
|
||||||
|
|
||||||
|
def _wrap_wifi_tls(self, plain_sock, hostname):
|
||||||
|
"""
|
||||||
|
在已建立的 TCP socket 上做 TLS(WiFi 走主机 ssl 库;4G 仍用模组 AT+SSL)。
|
||||||
|
校验与 config.SSL_VERIFY_MODE、SSL_CERT_PATH 一致。
|
||||||
|
"""
|
||||||
|
import ssl
|
||||||
|
|
||||||
|
verify_mode = getattr(config, "SSL_VERIFY_MODE", 0)
|
||||||
|
cert_path = getattr(config, "SSL_CERT_PATH", None) or ""
|
||||||
|
ca_ok = verify_mode == 1 and cert_path and os.path.exists(cert_path)
|
||||||
|
cert_none = getattr(ssl, "CERT_NONE", 0)
|
||||||
|
cert_required = getattr(ssl, "CERT_REQUIRED", 2)
|
||||||
|
|
||||||
|
if hasattr(ssl, "SSLContext"):
|
||||||
|
try:
|
||||||
|
proto = getattr(ssl, "PROTOCOL_TLS_CLIENT", None)
|
||||||
|
if proto is None:
|
||||||
|
proto = getattr(ssl, "PROTOCOL_TLS", 0)
|
||||||
|
ctx = ssl.SSLContext(proto)
|
||||||
|
if ca_ok:
|
||||||
|
ctx.load_verify_locations(cafile=cert_path)
|
||||||
|
ctx.verify_mode = cert_required
|
||||||
|
ctx.check_hostname = True
|
||||||
|
else:
|
||||||
|
ctx.check_hostname = False
|
||||||
|
ctx.verify_mode = cert_none
|
||||||
|
return ctx.wrap_socket(plain_sock, server_hostname=hostname)
|
||||||
|
except Exception as e:
|
||||||
|
self.logger.warning(f"[WIFI-TCP] SSLContext.wrap_socket 失败,改用 wrap_socket: {e}")
|
||||||
|
|
||||||
|
if ca_ok:
|
||||||
|
try:
|
||||||
|
return ssl.wrap_socket(
|
||||||
|
plain_sock,
|
||||||
|
server_hostname=hostname,
|
||||||
|
cert_reqs=cert_required,
|
||||||
|
ca_certs=cert_path,
|
||||||
|
)
|
||||||
|
except TypeError:
|
||||||
|
return ssl.wrap_socket(plain_sock, cert_reqs=cert_required, ca_certs=cert_path)
|
||||||
|
try:
|
||||||
|
return ssl.wrap_socket(plain_sock, server_hostname=hostname, cert_reqs=cert_none)
|
||||||
|
except TypeError:
|
||||||
|
return ssl.wrap_socket(plain_sock, cert_reqs=cert_none)
|
||||||
|
|
||||||
def _connect_tcp_via_wifi(self):
|
def _connect_tcp_via_wifi(self):
|
||||||
"""通过WiFi建立TCP连接"""
|
"""通过WiFi建立TCP连接(USE_TCP_SSL 时在 TCP 之上走 tls)"""
|
||||||
try:
|
try:
|
||||||
# 创建TCP socket
|
# 创建TCP socket
|
||||||
wifi_manager.wifi_socket = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
|
wifi_manager.wifi_socket = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
|
||||||
wifi_manager.wifi_socket.settimeout(5.0) # 5秒超时
|
wifi_manager.wifi_socket.settimeout(5.0) # 5秒超时
|
||||||
|
|
||||||
# 连接到服务器
|
# 连接到服务器
|
||||||
addr_info = socket.getaddrinfo(config.SERVER_IP, config.SERVER_PORT,
|
use_ssl = getattr(config, "USE_TCP_SSL", False)
|
||||||
socket.AF_INET, socket.SOCK_STREAM)[0]
|
host = self._server_ip
|
||||||
|
port = getattr(config, "TCP_SSL_PORT", 443) if use_ssl else config.SERVER_PORT
|
||||||
|
addr_info = socket.getaddrinfo(host, port, socket.AF_INET, socket.SOCK_STREAM)[0]
|
||||||
wifi_manager.wifi_socket.connect(addr_info[-1])
|
wifi_manager.wifi_socket.connect(addr_info[-1])
|
||||||
|
|
||||||
|
if use_ssl:
|
||||||
|
wifi_manager.wifi_socket = self._wrap_wifi_tls(wifi_manager.wifi_socket, host)
|
||||||
|
|
||||||
# 设置非阻塞模式(用于接收数据)
|
# 设置非阻塞模式(用于接收数据)
|
||||||
wifi_manager.wifi_socket.setblocking(False)
|
wifi_manager.wifi_socket.setblocking(False)
|
||||||
# 加快消息发送
|
# 加快消息发送
|
||||||
wifi_manager.wifi_socket.setsockopt(socket.IPPROTO_TCP, socket.TCP_NODELAY, 1)
|
wifi_manager.wifi_socket.setsockopt(socket.IPPROTO_TCP, socket.TCP_NODELAY, 1)
|
||||||
|
|
||||||
self._tcp_connected = True
|
self._tcp_connected = True
|
||||||
self.logger.info("[WIFI-TCP] TCP 连接已建立")
|
if use_ssl:
|
||||||
|
self.logger.info("[WIFI-TCP] TLS 连接已建立")
|
||||||
|
else:
|
||||||
|
self.logger.info("[WIFI-TCP] TCP 连接已建立")
|
||||||
|
|
||||||
# 启动 WiFi 质量后台检测
|
# 启动 WiFi 质量后台检测
|
||||||
self._start_wifi_quality_monitor()
|
self._start_wifi_quality_monitor()
|
||||||
@@ -670,6 +842,14 @@ class NetworkManager:
|
|||||||
"""检查WiFi TCP连接是否仍然有效"""
|
"""检查WiFi TCP连接是否仍然有效"""
|
||||||
if not wifi_manager.wifi_socket:
|
if not wifi_manager.wifi_socket:
|
||||||
return False
|
return False
|
||||||
|
# TLS(ssl.wrap_socket/SSLContext.wrap_socket) 后的 socket 往往不支持 MSG_PEEK/MSG_DONTWAIT。
|
||||||
|
# 这种情况下“主动探测”反而容易误报断线;让真正的 send/recv 去判定更稳。
|
||||||
|
try:
|
||||||
|
if getattr(config, "USE_TCP_SSL", False) or hasattr(wifi_manager.wifi_socket, "cipher"):
|
||||||
|
return True
|
||||||
|
except Exception:
|
||||||
|
# 任何探测异常都不应导致断线清理
|
||||||
|
return True
|
||||||
try:
|
try:
|
||||||
# send(b"") 在很多实现里是 no-op,无法可靠探测断线。
|
# send(b"") 在很多实现里是 no-op,无法可靠探测断线。
|
||||||
# 用非阻塞 peek 来判断:若对端已关闭,recv 会返回 b""。
|
# 用非阻塞 peek 来判断:若对端已关闭,recv 会返回 b""。
|
||||||
@@ -677,6 +857,13 @@ class NetworkManager:
|
|||||||
if data == b"":
|
if data == b"":
|
||||||
raise OSError("wifi socket closed")
|
raise OSError("wifi socket closed")
|
||||||
return True
|
return True
|
||||||
|
except TypeError as e:
|
||||||
|
# 某些实现(尤其是 TLS socket)不支持 flags 参数;不要误判断线
|
||||||
|
try:
|
||||||
|
self.logger.warning(f"[WIFI-TCP] conncheck flags unsupported (TypeError): {e}")
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
return True
|
||||||
except BlockingIOError:
|
except BlockingIOError:
|
||||||
# 无数据可读但连接仍在(EAGAIN)
|
# 无数据可读但连接仍在(EAGAIN)
|
||||||
return True
|
return True
|
||||||
@@ -810,7 +997,13 @@ class NetworkManager:
|
|||||||
# 标准 socket 发送
|
# 标准 socket 发送
|
||||||
total_sent = 0
|
total_sent = 0
|
||||||
while total_sent < len(data):
|
while total_sent < len(data):
|
||||||
sent = wifi_manager.wifi_socket.send(data[total_sent:])
|
try:
|
||||||
|
sent = wifi_manager.wifi_socket.send(data[total_sent:])
|
||||||
|
except (BlockingIOError, OSError) as se:
|
||||||
|
if _wifi_tls_would_block(se):
|
||||||
|
time.sleep_ms(2)
|
||||||
|
continue
|
||||||
|
raise
|
||||||
if sent == 0:
|
if sent == 0:
|
||||||
# socket连接已断开
|
# socket连接已断开
|
||||||
self.logger.warning(f"[WIFI-TCP] 发送失败,socket已断开(尝试 {attempt+1}/{max_retries})")
|
self.logger.warning(f"[WIFI-TCP] 发送失败,socket已断开(尝试 {attempt+1}/{max_retries})")
|
||||||
@@ -960,6 +1153,8 @@ class NetworkManager:
|
|||||||
# 无数据可读是正常的
|
# 无数据可读是正常的
|
||||||
return b""
|
return b""
|
||||||
except OSError as e:
|
except OSError as e:
|
||||||
|
if _wifi_tls_would_block(e):
|
||||||
|
return b""
|
||||||
# socket错误(连接断开等)
|
# socket错误(连接断开等)
|
||||||
self.logger.warning(f"[WIFI-TCP] 接收数据失败: {e}")
|
self.logger.warning(f"[WIFI-TCP] 接收数据失败: {e}")
|
||||||
|
|
||||||
@@ -1211,6 +1406,112 @@ class NetworkManager:
|
|||||||
self.logger.error(f"[LOG_UPLOAD] 上传异常: {e}")
|
self.logger.error(f"[LOG_UPLOAD] 上传异常: {e}")
|
||||||
self.safe_enqueue({"result": "log_upload_failed", "reason": str(e)[:100]}, 2)
|
self.safe_enqueue({"result": "log_upload_failed", "reason": str(e)[:100]}, 2)
|
||||||
|
|
||||||
|
def _upload_image_file(self, image_path, upload_url, upload_token, key, shoot_id, outlink):
|
||||||
|
"""上传图片文件到指定URL(自动检测网络类型,WiFi使用requests,4G使用AT HTTP命令)
|
||||||
|
|
||||||
|
Args:
|
||||||
|
image_path: 本地图片文件路径
|
||||||
|
upload_url: 上传目标URL,例如 "https://upload.qiniup.com"
|
||||||
|
upload_token: 上传token
|
||||||
|
key: 文件key,例如 "shootPic/123456.bmp"
|
||||||
|
shoot_id: 射击ID
|
||||||
|
outlink: 外链域名(可选,用于构建访问URL)
|
||||||
|
"""
|
||||||
|
# 自动检测网络类型,选择上传路径
|
||||||
|
if self._network_type == "wifi" and self.is_wifi_connected():
|
||||||
|
mode = "wifi"
|
||||||
|
else:
|
||||||
|
mode = "4g"
|
||||||
|
|
||||||
|
self.logger.info(f"[IMAGE_UPLOAD] Using {mode} path, image: {image_path}")
|
||||||
|
|
||||||
|
try:
|
||||||
|
if mode == "wifi":
|
||||||
|
# ---- WiFi path: 使用 requests 库上传 ----
|
||||||
|
import requests
|
||||||
|
import urllib3
|
||||||
|
urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning)
|
||||||
|
|
||||||
|
with open(image_path, 'rb') as f:
|
||||||
|
files = {'file': (os.path.basename(image_path), f, 'application/octet-stream')}
|
||||||
|
data = {'token': upload_token, 'key': key}
|
||||||
|
# 测试:将HTTPS转为HTTP
|
||||||
|
wifi_upload_url = upload_url.replace('https://', 'http://', 1)
|
||||||
|
self.logger.info(f"[IMAGE_UPLOAD] WiFi upload URL: {wifi_upload_url}")
|
||||||
|
response = requests.post(wifi_upload_url, files=files, data=data, timeout=120, verify=False)
|
||||||
|
response.raise_for_status()
|
||||||
|
result_json = response.json()
|
||||||
|
uploaded_key = result_json.get('key', key)
|
||||||
|
|
||||||
|
self.logger.info(f"[IMAGE_UPLOAD] WiFi upload ok: key={uploaded_key}")
|
||||||
|
|
||||||
|
access_url = None
|
||||||
|
if outlink:
|
||||||
|
access_url = f"https://{outlink}/{uploaded_key}"
|
||||||
|
|
||||||
|
response_data = {
|
||||||
|
"result": "image_upload_ok",
|
||||||
|
"shootId": shoot_id,
|
||||||
|
"key": uploaded_key,
|
||||||
|
"via": "wifi",
|
||||||
|
}
|
||||||
|
if access_url:
|
||||||
|
response_data["url"] = access_url
|
||||||
|
|
||||||
|
self.safe_enqueue(response_data, 2)
|
||||||
|
|
||||||
|
else:
|
||||||
|
# ---- 4G path: 使用 FourGUploadManager AT命令上传 ----
|
||||||
|
import importlib.util
|
||||||
|
spec = importlib.util.spec_from_file_location(
|
||||||
|
"four_g_upload_manager",
|
||||||
|
os.path.join(os.path.dirname(__file__), "4g_upload_manager.py")
|
||||||
|
)
|
||||||
|
upload_module = importlib.util.module_from_spec(spec)
|
||||||
|
spec.loader.exec_module(upload_module)
|
||||||
|
FourGUploadManager = upload_module.FourGUploadManager
|
||||||
|
|
||||||
|
# 实例化4G上传管理器
|
||||||
|
uploader = FourGUploadManager(hardware_manager.at_client)
|
||||||
|
|
||||||
|
# 执行上传
|
||||||
|
result = uploader.upload_image(image_path, upload_url, upload_token, key)
|
||||||
|
|
||||||
|
if result.get("success"):
|
||||||
|
uploaded_key = result.get("key", key)
|
||||||
|
self.logger.info(f"[IMAGE_UPLOAD] 4G upload ok: key={uploaded_key}")
|
||||||
|
|
||||||
|
access_url = None
|
||||||
|
if outlink:
|
||||||
|
access_url = f"https://{outlink}/{uploaded_key}"
|
||||||
|
|
||||||
|
response_data = {
|
||||||
|
"result": "image_upload_ok",
|
||||||
|
"shootId": shoot_id,
|
||||||
|
"key": uploaded_key,
|
||||||
|
"via": "4g",
|
||||||
|
}
|
||||||
|
if access_url:
|
||||||
|
response_data["url"] = access_url
|
||||||
|
|
||||||
|
self.safe_enqueue(response_data, 2)
|
||||||
|
else:
|
||||||
|
error_msg = result.get("error", "unknown_error")
|
||||||
|
self.logger.error(f"[IMAGE_UPLOAD] 4G upload failed: {error_msg}")
|
||||||
|
self.safe_enqueue({
|
||||||
|
"result": "image_upload_failed",
|
||||||
|
"shootId": shoot_id,
|
||||||
|
"reason": error_msg[:100]
|
||||||
|
}, 2)
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
self.logger.error(f"[IMAGE_UPLOAD] upload exception ({mode}): {e}")
|
||||||
|
self.safe_enqueue({
|
||||||
|
"result": "image_upload_failed",
|
||||||
|
"shootId": shoot_id,
|
||||||
|
"reason": str(e)[:100]
|
||||||
|
}, 2)
|
||||||
|
|
||||||
def generate_token(self, device_id):
|
def generate_token(self, device_id):
|
||||||
"""生成用于 HTTP 接口鉴权的 Token(HMAC-SHA256)"""
|
"""生成用于 HTTP 接口鉴权的 Token(HMAC-SHA256)"""
|
||||||
SALT = "shootMessageFire"
|
SALT = "shootMessageFire"
|
||||||
@@ -1258,6 +1559,8 @@ class NetworkManager:
|
|||||||
"vol": vol_val,
|
"vol": vol_val,
|
||||||
"vol_per": voltage_to_percent(vol_val)
|
"vol_per": voltage_to_percent(vol_val)
|
||||||
}
|
}
|
||||||
|
iccid_pending_marker = self._maybe_add_iccid_to_login(login_data)
|
||||||
|
print(f"login_data: {login_data}")
|
||||||
# if not self.tcp_send_raw(self.make_packet(1, login_data)):
|
# if not self.tcp_send_raw(self.make_packet(1, login_data)):
|
||||||
if not self.tcp_send_raw(self._netcore.make_packet(1, login_data)):
|
if not self.tcp_send_raw(self._netcore.make_packet(1, login_data)):
|
||||||
self._tcp_connected = False
|
self._tcp_connected = False
|
||||||
@@ -1291,265 +1594,338 @@ class NetworkManager:
|
|||||||
continue
|
continue
|
||||||
|
|
||||||
# 接收数据(根据网络类型选择接收方式)
|
# 接收数据(根据网络类型选择接收方式)
|
||||||
item = None
|
# WiFi 粘包:一次 recv 可能含多条完整包;也可能缓冲里已有完整包但本轮 recv 超时为空
|
||||||
|
rx_items = []
|
||||||
if self._network_type == "wifi":
|
if self._network_type == "wifi":
|
||||||
# WiFi接收数据
|
data = self.receive_tcp_data_via_wifi(timeout_ms=5)
|
||||||
data = self.receive_tcp_data_via_wifi(timeout_ms=50)
|
|
||||||
if data:
|
if data:
|
||||||
# 将数据添加到缓冲区
|
# self.logger.info(f"[NET] 接收WiFi数据, {time.time()}")
|
||||||
self.logger.info(f"[NET] 接收WiFi数据, {time.time()}")
|
|
||||||
wifi_manager.recv_buffer += data
|
wifi_manager.recv_buffer += data
|
||||||
|
while len(wifi_manager.recv_buffer) >= 12:
|
||||||
# 尝试从缓冲区解析完整的数据包
|
|
||||||
while len(wifi_manager.recv_buffer) >= 12: # 至少需要12字节的头部
|
|
||||||
# 解析头部
|
|
||||||
try:
|
|
||||||
body_len, msg_type, checksum = struct.unpack(">III", wifi_manager.recv_buffer[:12])
|
|
||||||
total_len = 12 + body_len
|
|
||||||
|
|
||||||
if len(wifi_manager.recv_buffer) >= total_len:
|
|
||||||
# 有完整的数据包
|
|
||||||
payload = wifi_manager.recv_buffer[:total_len]
|
|
||||||
wifi_manager.recv_buffer = wifi_manager.recv_buffer[total_len:]
|
|
||||||
item = (0, payload) # link_id=0 for WiFi
|
|
||||||
break
|
|
||||||
else:
|
|
||||||
# 数据包不完整,等待更多数据
|
|
||||||
self.logger.info(f"[NET] 接收WiFi数据不完整, {time.time()}")
|
|
||||||
break
|
|
||||||
except:
|
|
||||||
# 解析失败,清空缓冲区
|
|
||||||
wifi_manager.recv_buffer = b""
|
|
||||||
self.logger.info(f"[NET] 接收WiFi数据解析失败, {time.time()}")
|
|
||||||
break
|
|
||||||
elif self._network_type == "4g":
|
|
||||||
# 4G接收数据
|
|
||||||
item = hardware_manager.at_client.pop_tcp_payload()
|
|
||||||
|
|
||||||
if item:
|
|
||||||
if isinstance(item, tuple) and len(item) == 2:
|
|
||||||
link_id, payload = item
|
|
||||||
else:
|
|
||||||
link_id, payload = 0, item
|
|
||||||
|
|
||||||
if not logged_in:
|
|
||||||
try:
|
try:
|
||||||
self.logger.debug(f"[TCP] rx link={link_id} len={len(payload)} head={payload[:12].hex()}")
|
body_len, msg_type, checksum = struct.unpack(">III", wifi_manager.recv_buffer[:12])
|
||||||
except:
|
total_len = 12 + body_len
|
||||||
pass
|
if len(wifi_manager.recv_buffer) >= total_len:
|
||||||
|
payload = wifi_manager.recv_buffer[:total_len]
|
||||||
# msg_type, body = self.parse_packet(payload)
|
wifi_manager.recv_buffer = wifi_manager.recv_buffer[total_len:]
|
||||||
msg_type, body = self._netcore.parse_packet(payload)
|
rx_items.append((0, payload))
|
||||||
|
|
||||||
# 处理登录响应
|
|
||||||
if not logged_in and msg_type == 1:
|
|
||||||
if body and body.get("cmd") == 1 and body.get("data") == "登录成功":
|
|
||||||
logged_in = True
|
|
||||||
last_heartbeat_ack_time = time.ticks_ms()
|
|
||||||
self.logger.info("登录成功")
|
|
||||||
|
|
||||||
# 检查 ota_pending.json
|
|
||||||
try:
|
|
||||||
pending_path = f"{config.APP_DIR}/ota_pending.json"
|
|
||||||
if os.path.exists(pending_path):
|
|
||||||
try:
|
|
||||||
with open(pending_path, "r", encoding="utf-8") as f:
|
|
||||||
pending_obj = json.load(f)
|
|
||||||
except:
|
|
||||||
pending_obj = {}
|
|
||||||
self.safe_enqueue({"result": "ota_ok", "url": pending_obj.get("url", "")}, 2)
|
|
||||||
self.logger.info("[OTA] 已上报 ota_ok,等待心跳确认后删除 pending")
|
|
||||||
except Exception as e:
|
|
||||||
self.logger.error(f"[OTA] ota_ok 上报失败: {e}")
|
|
||||||
else:
|
|
||||||
break
|
|
||||||
|
|
||||||
# 处理心跳 ACK
|
|
||||||
elif logged_in and msg_type == 4:
|
|
||||||
last_heartbeat_ack_time = time.ticks_ms()
|
|
||||||
self.logger.debug("✅ 收到心跳确认")
|
|
||||||
|
|
||||||
# 处理命令40(分片下载)
|
|
||||||
elif logged_in and msg_type == 40:
|
|
||||||
if isinstance(body, dict):
|
|
||||||
t = body.get('t', 0)
|
|
||||||
v = body.get('v')
|
|
||||||
# 如果是第一个分片,清空之前的缓存
|
|
||||||
if len(self._raw_line_data) == 0 or (len(self._raw_line_data) > 0 and self._raw_line_data[0].get('v') != v):
|
|
||||||
self._raw_line_data.clear()
|
|
||||||
# 或者更简单:每次收到命令40时,如果版本号不同,清空缓存
|
|
||||||
if len(self._raw_line_data) > 0:
|
|
||||||
first_v = self._raw_line_data[0].get('v')
|
|
||||||
if first_v and first_v != v:
|
|
||||||
self._raw_line_data.clear()
|
|
||||||
self._raw_line_data.append(body)
|
|
||||||
if len(self._raw_line_data) >= int(t):
|
|
||||||
self.logger.info(f"下载完成")
|
|
||||||
from ota_manager import ota_manager
|
|
||||||
stock_array = list(map(lambda x: x.get('d'), self._raw_line_data))
|
|
||||||
local_filename = config.LOCAL_FILENAME
|
|
||||||
with open(local_filename, 'w', encoding='utf-8') as file:
|
|
||||||
file.write("\n".join(stock_array))
|
|
||||||
ota_manager.apply_ota_and_reboot(None, local_filename)
|
|
||||||
else:
|
else:
|
||||||
self.safe_enqueue({'data':{'l': len(self._raw_line_data), 'v': v}, 'cmd': 41})
|
self.logger.info(f"[NET] 接收WiFi数据不完整, {time.time()}")
|
||||||
self.logger.info(f"已下载{len(self._raw_line_data)} 全部:{t} 版本:{v}")
|
break
|
||||||
|
except Exception:
|
||||||
|
wifi_manager.recv_buffer = b""
|
||||||
|
self.logger.info(f"[NET] 接收WiFi数据解析失败, {time.time()}")
|
||||||
|
break
|
||||||
|
elif self._network_type == "4g":
|
||||||
|
item = hardware_manager.at_client.pop_tcp_payload()
|
||||||
|
if item:
|
||||||
|
rx_items.append(item)
|
||||||
|
|
||||||
# 处理业务指令
|
_rx_login_fail = False
|
||||||
elif logged_in and isinstance(body, dict):
|
_rx_skip_tcp_iteration = False
|
||||||
inner_cmd = None
|
if rx_items:
|
||||||
data_obj = body.get("data")
|
self.logger.info(f"total {len(rx_items)} items")
|
||||||
if isinstance(data_obj, dict):
|
for item in rx_items:
|
||||||
inner_cmd = data_obj.get("cmd")
|
if isinstance(item, tuple) and len(item) == 2:
|
||||||
if inner_cmd == 2: # 开启激光并校准
|
link_id, payload = item
|
||||||
from laser_manager import laser_manager
|
else:
|
||||||
if not laser_manager.calibration_active:
|
link_id, payload = 0, item
|
||||||
laser_manager.turn_on_laser()
|
|
||||||
time.sleep_ms(100)
|
|
||||||
hardware_manager.stop_idle_timer() # 停表
|
|
||||||
if not config.HARDCODE_LASER_POINT:
|
|
||||||
laser_manager.start_calibration()
|
|
||||||
self.safe_enqueue({"result": "calibrating"}, 2)
|
|
||||||
else:
|
|
||||||
# 写死的逻辑,不需要校准激光点
|
|
||||||
self.safe_enqueue({"result": "laser pos set by hard code"}, 2)
|
|
||||||
elif inner_cmd == 3: # 关闭激光
|
|
||||||
from laser_manager import laser_manager
|
|
||||||
laser_manager.turn_off_laser()
|
|
||||||
laser_manager.stop_calibration()
|
|
||||||
hardware_manager.start_idle_timer() # 开表
|
|
||||||
self.safe_enqueue({"result": "laser_off"}, 2)
|
|
||||||
elif inner_cmd == 4: # 上报电量
|
|
||||||
voltage = get_bus_voltage()
|
|
||||||
battery_percent = voltage_to_percent(voltage)
|
|
||||||
battery_data = {"battery": battery_percent, "voltage": round(voltage, 3)}
|
|
||||||
self.safe_enqueue(battery_data, 2)
|
|
||||||
self.logger.info(f"电量上报: {battery_percent}%")
|
|
||||||
elif inner_cmd == 5: # OTA 升级
|
|
||||||
inner_data = data_obj.get("data", {}) if isinstance(data_obj, dict) else {}
|
|
||||||
ssid = inner_data.get("ssid")
|
|
||||||
password = inner_data.get("password")
|
|
||||||
ota_url = inner_data.get("url")
|
|
||||||
mode = (inner_data.get("mode") or "").strip().lower()
|
|
||||||
|
|
||||||
if not ota_url:
|
if not logged_in:
|
||||||
self.logger.error("ota missing_url")
|
try:
|
||||||
self.safe_enqueue({"result": "missing_url"}, 2)
|
self.logger.debug(f"[TCP] rx link={link_id} len={len(payload)} head={payload[:12].hex()}")
|
||||||
continue
|
except:
|
||||||
|
pass
|
||||||
|
|
||||||
from ota_manager import ota_manager
|
# msg_type, body = self.parse_packet(payload)
|
||||||
if ota_manager.update_thread_started:
|
msg_type, body = self._netcore.parse_packet(payload)
|
||||||
self.safe_enqueue({"result": "update_already_started"}, 2)
|
|
||||||
continue
|
|
||||||
|
|
||||||
# 自动判断模式:如果没有明确指定,根据WiFi连接状态和凭证决定
|
# 处理登录响应
|
||||||
if mode not in ("4g", "wifi"):
|
if not logged_in and msg_type == 1:
|
||||||
self.logger.info("ota missing mode, auto-detecting...")
|
if body and body.get("cmd") == 1 and body.get("data") == "登录成功":
|
||||||
# 若本次会话已锁定 4G,则 OTA 自动也走 4G,避免后续回切导致体验不一致
|
logged_in = True
|
||||||
if self._session_force_4g:
|
last_heartbeat_ack_time = time.ticks_ms()
|
||||||
mode = "4g"
|
self.logger.info("登录成功")
|
||||||
self.logger.info("ota auto-selected: 4g (session locked on 4g)")
|
if iccid_pending_marker:
|
||||||
else:
|
self._create_iccid_marker_file()
|
||||||
# 只有同时满足:WiFi已连接 且 提供了WiFi凭证,才使用WiFi
|
iccid_pending_marker = False
|
||||||
if self.is_wifi_connected() and ssid and password:
|
|
||||||
mode = "wifi"
|
|
||||||
self.logger.info("ota auto-selected: wifi (WiFi connected and credentials provided)")
|
|
||||||
else:
|
|
||||||
mode = "4g"
|
|
||||||
self.logger.info("ota auto-selected: 4g (WiFi not available or no credentials)")
|
|
||||||
|
|
||||||
hardware_manager.stop_idle_timer() # 停表,注意OTA停表之后,就没有再开表,因为OTA后面会重启,会重新开表
|
# 检查 ota_pending.json
|
||||||
|
|
||||||
if mode == "4g":
|
|
||||||
ota_manager._set_ota_url(ota_url) # 记录 OTA URL,供命令7使用
|
|
||||||
ota_manager._start_update_thread()
|
|
||||||
_thread.start_new_thread(ota_manager.direct_ota_download_via_4g, (ota_url,))
|
|
||||||
else: # mode == "wifi"
|
|
||||||
if not ssid or not password:
|
|
||||||
self.logger.error("ota wifi mode requires ssid and password")
|
|
||||||
self.safe_enqueue({"result": "missing_ssid_or_password"}, 2)
|
|
||||||
else:
|
|
||||||
ota_manager._start_update_thread()
|
|
||||||
_thread.start_new_thread(ota_manager.handle_wifi_and_update, (ssid, password, ota_url))
|
|
||||||
elif inner_cmd == 6:
|
|
||||||
try:
|
try:
|
||||||
ip = os.popen("ifconfig wlan0 2>/dev/null | grep 'inet ' | awk '{print $2}'").read().strip()
|
pending_path = f"{config.APP_DIR}/ota_pending.json"
|
||||||
ip = ip if ip else "no_ip"
|
if os.path.exists(pending_path):
|
||||||
except:
|
try:
|
||||||
ip = "error_getting_ip"
|
with open(pending_path, "r", encoding="utf-8") as f:
|
||||||
self.safe_enqueue({"result": "current_ip", "ip": ip}, 2)
|
pending_obj = json.load(f)
|
||||||
# elif inner_cmd == 7:
|
except:
|
||||||
# from ota_manager import ota_manager
|
pending_obj = {}
|
||||||
# if ota_manager.update_thread_started:
|
self.safe_enqueue({"result": "ota_ok", "url": pending_obj.get("url", "")}, 2)
|
||||||
# self.safe_enqueue({"result": "update_already_started"}, 2)
|
self.logger.info("[OTA] 已上报 ota_ok,等待心跳确认后删除 pending")
|
||||||
# continue
|
except Exception as e:
|
||||||
|
self.logger.error(f"[OTA] ota_ok 上报失败: {e}")
|
||||||
|
else:
|
||||||
|
_rx_login_fail = True
|
||||||
|
break
|
||||||
|
|
||||||
# try:
|
# 处理心跳 ACK
|
||||||
# ip = os.popen("ifconfig wlan0 2>/dev/null | grep 'inet ' | awk '{print $2}'").read().strip()
|
elif logged_in and msg_type == 4:
|
||||||
# except:
|
last_heartbeat_ack_time = time.ticks_ms()
|
||||||
# ip = None
|
self.logger.debug("✅ 收到心跳确认")
|
||||||
|
|
||||||
# if not ip:
|
# 处理命令40(分片下载)
|
||||||
# self.safe_enqueue({"result": "ota_rejected", "reason": "no_wifi_ip"}, 2)
|
elif logged_in and msg_type == 40:
|
||||||
# else:
|
if isinstance(body, dict):
|
||||||
# # 注意:direct_ota_download 需要 ota_url 参数
|
t = body.get('t', 0)
|
||||||
# # 如果 ota_manager.ota_url 为 None,需要从其他地方获取
|
v = body.get('v')
|
||||||
# ota_url_to_use = ota_manager.ota_url
|
# 如果是第一个分片,清空之前的缓存
|
||||||
# if not ota_url_to_use:
|
if len(self._raw_line_data) == 0 or (len(self._raw_line_data) > 0 and self._raw_line_data[0].get('v') != v):
|
||||||
# self.logger.error("[OTA] cmd=7 但 OTA_URL 未设置")
|
self._raw_line_data.clear()
|
||||||
# self.safe_enqueue({"result": "ota_failed", "reason": "ota_url_not_set"}, 2)
|
# 或者更简单:每次收到命令40时,如果版本号不同,清空缓存
|
||||||
# else:
|
if len(self._raw_line_data) > 0:
|
||||||
# ota_manager._start_update_thread()
|
first_v = self._raw_line_data[0].get('v')
|
||||||
# _thread.start_new_thread(ota_manager.direct_ota_download, (ota_url_to_use,))
|
if first_v and first_v != v:
|
||||||
elif inner_cmd == 41:
|
self._raw_line_data.clear()
|
||||||
self.logger.info(f"[TEST] 收到TCP射箭触发命令, {time.time()}")
|
self._raw_line_data.append(body)
|
||||||
self._manual_trigger_flag = True
|
if len(self._raw_line_data) >= int(t):
|
||||||
self.safe_enqueue({"result": "trigger_ack"}, 2)
|
self.logger.info(f"下载完成")
|
||||||
hardware_manager.start_idle_timer() # 重新计时
|
from ota_manager import ota_manager
|
||||||
elif inner_cmd == 42: # 关机命令
|
stock_array = list(map(lambda x: x.get('d'), self._raw_line_data))
|
||||||
self.logger.info("[SHUTDOWN] 收到TCP关机命令,准备关机...")
|
local_filename = config.LOCAL_FILENAME
|
||||||
self.safe_enqueue({"result": "shutdown_ack"}, 2)
|
with open(local_filename, 'w', encoding='utf-8') as file:
|
||||||
time.sleep_ms(1000)
|
file.write("\n".join(stock_array))
|
||||||
self.disconnect_server()
|
ota_manager.apply_ota_and_reboot(None, local_filename)
|
||||||
# 尝试关闭4G模块
|
else:
|
||||||
try:
|
self.safe_enqueue({'data':{'l': len(self._raw_line_data), 'v': v}, 'cmd': 41})
|
||||||
with self.get_uart_lock():
|
self.logger.info(f"已下载{len(self._raw_line_data)} 全部:{t} 版本:{v}")
|
||||||
hardware_manager.at_client.send("AT+CFUN=0", "OK", 5000)
|
|
||||||
except:
|
elif logged_in and msg_type == 100:
|
||||||
pass
|
self.logger.info(f"[IMAGE_UPLOAD] 收到图片上传命令 {body}")
|
||||||
time.sleep_ms(2000)
|
if isinstance(body, dict):
|
||||||
os.system("sync") # 刷新文件系统缓存到磁盘,防止数据丢失
|
|
||||||
time.sleep_ms(500)
|
upload_url = body.get("uploadUrl")
|
||||||
# os.system("poweroff")
|
upload_token = body.get("token")
|
||||||
hardware_manager.power_off()
|
shoot_id = body.get("shootId")
|
||||||
return
|
outlink = body.get("outlink", "")
|
||||||
elif inner_cmd == 43: # 上传日志命令
|
|
||||||
# 格式: {"cmd":43, "data":{"ssid":"xxx","password":"xxx","url":"xxx", ...}}
|
|
||||||
inner_data = data_obj.get("data", {})
|
|
||||||
upload_url = inner_data.get("url")
|
|
||||||
wifi_ssid = inner_data.get("ssid")
|
|
||||||
wifi_password = inner_data.get("password")
|
|
||||||
include_rotated = inner_data.get("include_rotated", True)
|
|
||||||
max_files = inner_data.get("max_files")
|
|
||||||
archive_format = inner_data.get("archive", "tgz") # tgz 或 zip
|
|
||||||
|
|
||||||
hardware_manager.start_idle_timer() # 重新计时
|
hardware_manager.start_idle_timer() # 重新计时
|
||||||
|
|
||||||
if not upload_url:
|
# 验证必需字段
|
||||||
self.logger.error("[LOG_UPLOAD] 缺少 url 参数")
|
if not upload_url or not upload_token or not shoot_id:
|
||||||
self.safe_enqueue({"result": "log_upload_failed", "reason": "missing_url"}, 2)
|
self.logger.error("[IMAGE_UPLOAD] 缺少必需参数: uploadUrl, token 或 shootId")
|
||||||
|
self.safe_enqueue({"result": "image_upload_failed", "reason": "missing_params"}, 2)
|
||||||
else:
|
else:
|
||||||
self.logger.info(f"[LOG_UPLOAD] 收到日志上传命令,目标URL: {upload_url}")
|
self.logger.info(f"[IMAGE_UPLOAD] 收到图片上传命令,shootId: {shoot_id}")
|
||||||
# 在新线程中执行上传,避免阻塞主循环
|
# 查找文件名中包含 shoot_id 的图片文件(文件名格式:shot_{shoot_id}_*.bmp)
|
||||||
import _thread
|
image_extensions = ('.bmp', '.jpg', '.jpeg', '.png')
|
||||||
_thread.start_new_thread(
|
photo_dir = config.PHOTO_DIR
|
||||||
self._upload_log_file,
|
target_image = None
|
||||||
(upload_url, wifi_ssid, wifi_password, include_rotated, max_files, archive_format)
|
try:
|
||||||
)
|
if os.path.isdir(photo_dir):
|
||||||
else: # data的结构不是 dict
|
# 优先查找文件名中包含 shoot_id 的图片
|
||||||
self.logger.info(f"[NET] body={body}, {time.time()}")
|
matched_images = [
|
||||||
else:
|
f for f in os.listdir(photo_dir)
|
||||||
self.logger.info(f"[NET] 未知数据 {body}, {time.time()}")
|
if f.lower().endswith(image_extensions) and shoot_id in f
|
||||||
|
]
|
||||||
|
if matched_images:
|
||||||
|
# 按修改时间排序,取最新的匹配文件
|
||||||
|
matched_images.sort(
|
||||||
|
key=lambda f: os.path.getmtime(os.path.join(photo_dir, f)),
|
||||||
|
reverse=True
|
||||||
|
)
|
||||||
|
target_image = os.path.join(photo_dir, matched_images[0])
|
||||||
|
self.logger.info(f"[IMAGE_UPLOAD] 找到匹配shootId的图片: {matched_images[0]}")
|
||||||
|
else:
|
||||||
|
self.logger.warning(f"[IMAGE_UPLOAD] 未找到包含shootId={shoot_id}的图片文件")
|
||||||
|
except Exception as e:
|
||||||
|
self.logger.error(f"[IMAGE_UPLOAD] 查找图片失败: {e}")
|
||||||
|
|
||||||
|
if not target_image:
|
||||||
|
self.logger.error(f"[IMAGE_UPLOAD] 未找到shootId={shoot_id}对应的图片文件")
|
||||||
|
self.safe_enqueue({"result": "image_upload_failed", "reason": "no_image_found", "shootId": shoot_id}, 2)
|
||||||
|
else:
|
||||||
|
# 构建上传key
|
||||||
|
ext = os.path.splitext(target_image)[1].lower()
|
||||||
|
key = f"shootPic/{self.device_id}/{shoot_id}{ext}"
|
||||||
|
self.logger.info(f"[IMAGE_UPLOAD] 准备上传: {target_image} -> {key}")
|
||||||
|
|
||||||
|
# 在新线程中执行上传,避免阻塞主循环
|
||||||
|
import _thread
|
||||||
|
_thread.start_new_thread(
|
||||||
|
self._upload_image_file,
|
||||||
|
(target_image, upload_url, upload_token, key, shoot_id, outlink)
|
||||||
|
)
|
||||||
|
# 立即返回已入队确认
|
||||||
|
self.safe_enqueue({"result": "image_upload_queued", "shootId": shoot_id}, 2)
|
||||||
|
# 处理业务指令
|
||||||
|
elif logged_in and isinstance(body, dict):
|
||||||
|
inner_cmd = None
|
||||||
|
data_obj = body.get("data")
|
||||||
|
if isinstance(data_obj, dict):
|
||||||
|
inner_cmd = data_obj.get("cmd")
|
||||||
|
if inner_cmd == 2: # 开启激光并校准
|
||||||
|
from laser_manager import laser_manager
|
||||||
|
if not laser_manager.calibration_active:
|
||||||
|
laser_manager.turn_on_laser()
|
||||||
|
time.sleep_ms(100)
|
||||||
|
hardware_manager.stop_idle_timer() # 停表
|
||||||
|
if not config.HARDCODE_LASER_POINT:
|
||||||
|
laser_manager.start_calibration()
|
||||||
|
self.safe_enqueue({"result": "calibrating"}, 2)
|
||||||
|
else:
|
||||||
|
# 写死的逻辑,不需要校准激光点
|
||||||
|
self.safe_enqueue({"result": "laser pos set by hard code"}, 2)
|
||||||
|
elif inner_cmd == 3: # 关闭激光
|
||||||
|
from laser_manager import laser_manager
|
||||||
|
laser_manager.turn_off_laser()
|
||||||
|
laser_manager.stop_calibration()
|
||||||
|
hardware_manager.start_idle_timer() # 开表
|
||||||
|
self.safe_enqueue({"result": "laser_off"}, 2)
|
||||||
|
elif inner_cmd == 4: # 上报电量
|
||||||
|
voltage = get_bus_voltage()
|
||||||
|
battery_percent = voltage_to_percent(voltage)
|
||||||
|
battery_data = {"battery": battery_percent, "voltage": round(voltage, 3)}
|
||||||
|
self.safe_enqueue(battery_data, 2)
|
||||||
|
self.logger.info(f"电量上报: {battery_percent}%")
|
||||||
|
elif inner_cmd == 5: # OTA 升级
|
||||||
|
inner_data = data_obj.get("data", {}) if isinstance(data_obj, dict) else {}
|
||||||
|
ssid = inner_data.get("ssid")
|
||||||
|
password = inner_data.get("password")
|
||||||
|
ota_url = inner_data.get("url")
|
||||||
|
mode = (inner_data.get("mode") or "").strip().lower()
|
||||||
|
|
||||||
|
if not ota_url:
|
||||||
|
self.logger.error("ota missing_url")
|
||||||
|
self.safe_enqueue({"result": "missing_url"}, 2)
|
||||||
|
_rx_skip_tcp_iteration = True
|
||||||
|
break
|
||||||
|
|
||||||
|
from ota_manager import ota_manager
|
||||||
|
if ota_manager.update_thread_started:
|
||||||
|
self.safe_enqueue({"result": "update_already_started"}, 2)
|
||||||
|
_rx_skip_tcp_iteration = True
|
||||||
|
break
|
||||||
|
|
||||||
|
# 自动判断模式:如果没有明确指定,根据WiFi连接状态和凭证决定
|
||||||
|
if mode not in ("4g", "wifi"):
|
||||||
|
self.logger.info("ota missing mode, auto-detecting...")
|
||||||
|
# 若本次会话已锁定 4G,则 OTA 自动也走 4G,避免后续回切导致体验不一致
|
||||||
|
if self._session_force_4g:
|
||||||
|
mode = "4g"
|
||||||
|
self.logger.info("ota auto-selected: 4g (session locked on 4g)")
|
||||||
|
else:
|
||||||
|
# 只有同时满足:WiFi已连接 且 提供了WiFi凭证,才使用WiFi
|
||||||
|
if self.is_wifi_connected() and ssid and password:
|
||||||
|
mode = "wifi"
|
||||||
|
self.logger.info("ota auto-selected: wifi (WiFi connected and credentials provided)")
|
||||||
|
else:
|
||||||
|
mode = "4g"
|
||||||
|
self.logger.info("ota auto-selected: 4g (WiFi not available or no credentials)")
|
||||||
|
|
||||||
|
hardware_manager.stop_idle_timer() # 停表,注意OTA停表之后,就没有再开表,因为OTA后面会重启,会重新开表
|
||||||
|
|
||||||
|
if mode == "4g":
|
||||||
|
ota_manager._set_ota_url(ota_url) # 记录 OTA URL,供命令7使用
|
||||||
|
ota_manager._start_update_thread()
|
||||||
|
_thread.start_new_thread(ota_manager.direct_ota_download_via_4g, (ota_url,))
|
||||||
|
else: # mode == "wifi"
|
||||||
|
if not ssid or not password:
|
||||||
|
self.logger.error("ota wifi mode requires ssid and password")
|
||||||
|
self.safe_enqueue({"result": "missing_ssid_or_password"}, 2)
|
||||||
|
else:
|
||||||
|
ota_manager._start_update_thread()
|
||||||
|
_thread.start_new_thread(ota_manager.handle_wifi_and_update, (ssid, password, ota_url))
|
||||||
|
elif inner_cmd == 6:
|
||||||
|
try:
|
||||||
|
ip = os.popen("ifconfig wlan0 2>/dev/null | grep 'inet ' | awk '{print $2}'").read().strip()
|
||||||
|
ip = ip if ip else "no_ip"
|
||||||
|
except:
|
||||||
|
ip = "error_getting_ip"
|
||||||
|
self.safe_enqueue({"result": "current_ip", "ip": ip}, 2)
|
||||||
|
elif inner_cmd == 44: # 读 4G 本机号码(AT+CNUM)
|
||||||
|
cnum = self.get_4g_phone_number()
|
||||||
|
self.logger.info(f"4G 本机号码: {cnum}")
|
||||||
|
self.safe_enqueue({"result": "cnum", "number": cnum if cnum is not None else ""}, 2)
|
||||||
|
elif inner_cmd == 45: # 读 MCCID(AT+MCCID)
|
||||||
|
mccid = self.get_4g_mccid()
|
||||||
|
self.logger.info(f"4G MCCID: {mccid}")
|
||||||
|
self.safe_enqueue({"result": "mccid", "mccid": mccid if mccid is not None else ""}, 2)
|
||||||
|
# elif inner_cmd == 7:
|
||||||
|
# from ota_manager import ota_manager
|
||||||
|
# if ota_manager.update_thread_started:
|
||||||
|
# self.safe_enqueue({"result": "update_already_started"}, 2)
|
||||||
|
# continue
|
||||||
|
|
||||||
|
# try:
|
||||||
|
# ip = os.popen("ifconfig wlan0 2>/dev/null | grep 'inet ' | awk '{print $2}'").read().strip()
|
||||||
|
# except:
|
||||||
|
# ip = None
|
||||||
|
|
||||||
|
# if not ip:
|
||||||
|
# self.safe_enqueue({"result": "ota_rejected", "reason": "no_wifi_ip"}, 2)
|
||||||
|
# else:
|
||||||
|
# # 注意:direct_ota_download 需要 ota_url 参数
|
||||||
|
# # 如果 ota_manager.ota_url 为 None,需要从其他地方获取
|
||||||
|
# ota_url_to_use = ota_manager.ota_url
|
||||||
|
# if not ota_url_to_use:
|
||||||
|
# self.logger.error("[OTA] cmd=7 但 OTA_URL 未设置")
|
||||||
|
# self.safe_enqueue({"result": "ota_failed", "reason": "ota_url_not_set"}, 2)
|
||||||
|
# else:
|
||||||
|
# ota_manager._start_update_thread()
|
||||||
|
# _thread.start_new_thread(ota_manager.direct_ota_download, (ota_url_to_use,))
|
||||||
|
elif inner_cmd == 41:
|
||||||
|
self.logger.info(f"[TEST] 收到TCP射箭触发命令, {time.time()}")
|
||||||
|
self._manual_trigger_flag = True
|
||||||
|
self.safe_enqueue({"result": "trigger_ack"}, 2)
|
||||||
|
hardware_manager.start_idle_timer() # 重新计时
|
||||||
|
elif inner_cmd == 42: # 关机命令
|
||||||
|
self.logger.info("[SHUTDOWN] 收到TCP关机命令,准备关机...")
|
||||||
|
self.safe_enqueue({"result": "shutdown_ack"}, 2)
|
||||||
|
time.sleep_ms(1000)
|
||||||
|
self.disconnect_server()
|
||||||
|
# 尝试关闭4G模块
|
||||||
|
try:
|
||||||
|
with self.get_uart_lock():
|
||||||
|
hardware_manager.at_client.send("AT+CFUN=0", "OK", 5000)
|
||||||
|
except:
|
||||||
|
pass
|
||||||
|
time.sleep_ms(2000)
|
||||||
|
os.system("sync") # 刷新文件系统缓存到磁盘,防止数据丢失
|
||||||
|
time.sleep_ms(500)
|
||||||
|
# os.system("poweroff")
|
||||||
|
hardware_manager.power_off()
|
||||||
|
return
|
||||||
|
elif inner_cmd == 43: # 上传日志命令
|
||||||
|
# 格式: {"cmd":43, "data":{"ssid":"xxx","password":"xxx","url":"xxx", ...}}
|
||||||
|
inner_data = data_obj.get("data", {})
|
||||||
|
upload_url = inner_data.get("url")
|
||||||
|
wifi_ssid = inner_data.get("ssid")
|
||||||
|
wifi_password = inner_data.get("password")
|
||||||
|
include_rotated = inner_data.get("include_rotated", True)
|
||||||
|
max_files = inner_data.get("max_files")
|
||||||
|
archive_format = inner_data.get("archive", "tgz") # tgz 或 zip
|
||||||
|
|
||||||
|
hardware_manager.start_idle_timer() # 重新计时
|
||||||
|
|
||||||
|
if not upload_url:
|
||||||
|
self.logger.error("[LOG_UPLOAD] 缺少 url 参数")
|
||||||
|
self.safe_enqueue({"result": "log_upload_failed", "reason": "missing_url"}, 2)
|
||||||
|
else:
|
||||||
|
self.logger.info(f"[LOG_UPLOAD] 收到日志上传命令,目标URL: {upload_url}")
|
||||||
|
# 在新线程中执行上传,避免阻塞主循环
|
||||||
|
import _thread
|
||||||
|
_thread.start_new_thread(
|
||||||
|
self._upload_log_file,
|
||||||
|
(upload_url, wifi_ssid, wifi_password, include_rotated, max_files, archive_format)
|
||||||
|
)
|
||||||
|
|
||||||
|
else: # data的结构不是 dict
|
||||||
|
self.logger.info(f"[NET] body={body}, {time.time()}")
|
||||||
|
else:
|
||||||
|
self.logger.info(f"[NET] 未知数据 {body}, {time.time()}")
|
||||||
|
if _rx_login_fail:
|
||||||
|
break
|
||||||
|
if _rx_skip_tcp_iteration:
|
||||||
|
continue
|
||||||
else:
|
else:
|
||||||
time.sleep_ms(5)
|
time.sleep_ms(5)
|
||||||
|
|
||||||
|
|||||||
33
server.pem
Normal file
33
server.pem
Normal 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-----
|
||||||
362
shoot_manager.py
362
shoot_manager.py
@@ -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,128 @@ 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', {})
|
||||||
|
|
||||||
|
if tri.get('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": tri.get("offset_method") or "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 +199,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 +223,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 = offset_method or "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 +246,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 +289,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})cm),ID: {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
36
test/test_camera_rtsp.py
Normal 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_RGB888(JPEG 编码需要 RGB 输入)
|
||||||
|
cam = camera.Camera(640, 480, image.Format.FMT_RGB888)
|
||||||
|
|
||||||
|
# 创建 JPEG 流服务器
|
||||||
|
stream = http.JpegStreamer()
|
||||||
|
stream.start()
|
||||||
|
|
||||||
|
print("RTSP 替代方案 - HTTP JPEG 流地址: http://{}:{}".format(stream.host(), stream.port()))
|
||||||
|
print("请在浏览器或 OpenCV 中访问: http://<MaixCAM_IP>:8000/stream")
|
||||||
|
|
||||||
|
while not app.need_exit():
|
||||||
|
img = cam.read()
|
||||||
|
jpg = img.to_jpeg() # 将 RGB 图像编码为 JPEG
|
||||||
|
stream.write(jpg) # 推送到 HTTP 客户端
|
||||||
6
triangle_positions.json
Normal file
6
triangle_positions.json
Normal 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]
|
||||||
|
}
|
||||||
592
triangle_target.py
Normal file
592
triangle_target.py
Normal file
@@ -0,0 +1,592 @@
|
|||||||
|
#!/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 上图像处理耗时与面积成正比,缩到最长边 320px 可获得 ~4× 加速
|
||||||
|
# 检测完后把像素坐标乘以 inv_scale 还原到原始分辨率,再送入单应性/PnP(与 K 标定分辨率一致)
|
||||||
|
MAX_DETECT_DIM = 640
|
||||||
|
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"
|
||||||
|
|
||||||
|
ok_h, tx, ty, _H = homography_calibration(
|
||||||
|
marker_centers, marker_ids, marker_positions, [lx, ly]
|
||||||
|
)
|
||||||
|
if not ok_h:
|
||||||
|
_log("[TRI] 单应性失败")
|
||||||
|
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)
|
||||||
|
if dist_m is not None and 0.3 < dist_m < 50.0:
|
||||||
|
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
|
||||||
@@ -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 增加三角形的单应性算法,适配对应的靶纸
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
944
vision.py
Normal file
944
vision.py
Normal file
@@ -0,0 +1,944 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
"""
|
||||||
|
视觉检测模块
|
||||||
|
提供靶心检测、距离估算、图像保存等功能
|
||||||
|
"""
|
||||||
|
import cv2
|
||||||
|
import numpy as np
|
||||||
|
import os
|
||||||
|
import math
|
||||||
|
import threading
|
||||||
|
import queue
|
||||||
|
from maix import image
|
||||||
|
import config
|
||||||
|
from logger_manager import logger_manager
|
||||||
|
|
||||||
|
# 导入ArUco检测器(如果启用)
|
||||||
|
if config.USE_ARUCO:
|
||||||
|
from aruco_detector import detect_target_with_aruco, aruco_detector
|
||||||
|
|
||||||
|
# 存图队列 + worker
|
||||||
|
_save_queue = queue.Queue(maxsize=16)
|
||||||
|
_save_worker_started = False
|
||||||
|
_save_worker_lock = threading.Lock()
|
||||||
|
|
||||||
|
def check_laser_point_sharpness(frame, laser_point=None, roi_size=30, threshold=100.0, ellipse_params=None):
|
||||||
|
"""
|
||||||
|
检测激光点本身的清晰度(不是整个靶子)
|
||||||
|
|
||||||
|
Args:
|
||||||
|
frame: 图像帧对象
|
||||||
|
laser_point: 激光点坐标 (x, y),如果为None则自动查找
|
||||||
|
roi_size: ROI区域大小(像素),默认30x30
|
||||||
|
threshold: 清晰度阈值
|
||||||
|
ellipse_params: 椭圆参数 ((center_x, center_y), (width, height), angle),用于限制激光点必须在椭圆内
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
(is_sharp, sharpness_score, laser_pos): (是否清晰, 清晰度分数, 激光点坐标)
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
# 1. 如果没有提供激光点,先查找
|
||||||
|
if laser_point is None:
|
||||||
|
from laser_manager import laser_manager
|
||||||
|
laser_point = laser_manager.find_red_laser(frame, ellipse_params=ellipse_params)
|
||||||
|
if laser_point is None:
|
||||||
|
logger_manager.logger.debug(f"未找到激光点")
|
||||||
|
return False, 0.0, None
|
||||||
|
|
||||||
|
x, y = laser_point
|
||||||
|
|
||||||
|
# 2. 转换为 OpenCV 格式
|
||||||
|
img_cv = image.image2cv(frame, False, False)
|
||||||
|
h, w = img_cv.shape[:2]
|
||||||
|
|
||||||
|
# 3. 提取 ROI 区域(激光点周围)
|
||||||
|
roi_half = roi_size // 2
|
||||||
|
x_min = max(0, int(x) - roi_half)
|
||||||
|
x_max = min(w, int(x) + roi_half)
|
||||||
|
y_min = max(0, int(y) - roi_half)
|
||||||
|
y_max = min(h, int(y) + roi_half)
|
||||||
|
|
||||||
|
roi = img_cv[y_min:y_max, x_min:x_max]
|
||||||
|
|
||||||
|
if roi.size == 0:
|
||||||
|
return False, 0.0, laser_point
|
||||||
|
|
||||||
|
# 4. 转换为灰度图(用于清晰度检测)
|
||||||
|
gray_roi = cv2.cvtColor(roi, cv2.COLOR_RGB2GRAY)
|
||||||
|
|
||||||
|
# 5. 方法1:检测点的扩散程度(能量集中度)
|
||||||
|
# 计算中心区域的能量集中度
|
||||||
|
center_x, center_y = roi.shape[1] // 2, roi.shape[0] // 2
|
||||||
|
center_radius = min(5, roi.shape[0] // 4) # 中心区域半径
|
||||||
|
|
||||||
|
# 创建中心区域的掩码
|
||||||
|
y_coords, x_coords = np.ogrid[:roi.shape[0], :roi.shape[1]]
|
||||||
|
center_mask = (x_coords - center_x)**2 + (y_coords - center_y)**2 <= center_radius**2
|
||||||
|
|
||||||
|
# 计算中心区域和周围区域的亮度
|
||||||
|
center_brightness = gray_roi[center_mask].mean()
|
||||||
|
outer_mask = ~center_mask
|
||||||
|
outer_brightness = gray_roi[outer_mask].mean() if np.any(outer_mask) else 0
|
||||||
|
|
||||||
|
# 对比度(清晰的点对比度高)
|
||||||
|
contrast = abs(center_brightness - outer_brightness)
|
||||||
|
|
||||||
|
# 6. 方法2:检测点的边缘锐度(使用拉普拉斯)
|
||||||
|
laplacian = cv2.Laplacian(gray_roi, cv2.CV_64F)
|
||||||
|
edge_sharpness = abs(laplacian).var()
|
||||||
|
|
||||||
|
# 7. 方法3:检测点的能量集中度(方差)
|
||||||
|
# 清晰的点:能量集中在中心,方差小
|
||||||
|
# 模糊的点:能量分散,方差大
|
||||||
|
# 但我们需要的是:清晰的点中心亮度高,周围低,所以梯度大
|
||||||
|
sobel_x = cv2.Sobel(gray_roi, cv2.CV_64F, 1, 0, ksize=3)
|
||||||
|
sobel_y = cv2.Sobel(gray_roi, cv2.CV_64F, 0, 1, ksize=3)
|
||||||
|
gradient = np.sqrt(sobel_x**2 + sobel_y**2)
|
||||||
|
gradient_sharpness = gradient.var()
|
||||||
|
|
||||||
|
# 8. 组合多个指标
|
||||||
|
# 对比度权重0.3,边缘锐度权重0.4,梯度权重0.3
|
||||||
|
sharpness_score = (contrast * 0.3 + edge_sharpness * 0.4 + gradient_sharpness * 0.3)
|
||||||
|
|
||||||
|
is_sharp = sharpness_score >= threshold
|
||||||
|
|
||||||
|
logger = logger_manager.logger
|
||||||
|
if logger:
|
||||||
|
logger.debug(f"[VISION] 激光点清晰度: 位置=({x}, {y}), 对比度={contrast:.2f}, 边缘={edge_sharpness:.2f}, 梯度={gradient_sharpness:.2f}, 综合={sharpness_score:.2f}, 是否清晰={is_sharp}")
|
||||||
|
|
||||||
|
return is_sharp, sharpness_score, laser_point
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger = logger_manager.logger
|
||||||
|
if logger:
|
||||||
|
logger.error(f"[VISION] 激光点清晰度检测失败: {e}")
|
||||||
|
import traceback
|
||||||
|
logger.error(traceback.format_exc())
|
||||||
|
return False, 0.0, laser_point
|
||||||
|
|
||||||
|
def check_image_sharpness(frame, threshold=100.0, save_debug_images=False):
|
||||||
|
"""
|
||||||
|
检查图像清晰度(针对圆形靶子优化,基于圆形边缘检测)
|
||||||
|
检测靶心的圆形边缘,计算边缘区域的梯度清晰度
|
||||||
|
|
||||||
|
Args:
|
||||||
|
frame: 图像帧对象
|
||||||
|
threshold: 清晰度阈值,低于此值认为图像模糊(默认100.0)
|
||||||
|
可以根据实际情况调整:
|
||||||
|
- 清晰图像通常 > 200
|
||||||
|
- 模糊图像通常 < 100
|
||||||
|
- 中等清晰度 100-200
|
||||||
|
save_debug_images: 是否保存调试图像(原始图和边缘图),默认False
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
(is_sharp, sharpness_score): (是否清晰, 清晰度分数)
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
logger_manager.logger.debug(f"begin")
|
||||||
|
# 转换为 OpenCV 格式
|
||||||
|
img_cv = image.image2cv(frame, False, False)
|
||||||
|
logger_manager.logger.debug(f"after image2cv")
|
||||||
|
|
||||||
|
# 转换为 HSV 颜色空间
|
||||||
|
hsv = cv2.cvtColor(img_cv, cv2.COLOR_RGB2HSV)
|
||||||
|
h, s, v = cv2.split(hsv)
|
||||||
|
logger_manager.logger.debug(f"after HSV conversion")
|
||||||
|
|
||||||
|
# 检测黄色区域(靶心)
|
||||||
|
# 调整饱和度策略:稍微增强,不要过度
|
||||||
|
s_enhanced = np.clip(s * 1.1, 0, 255).astype(np.uint8)
|
||||||
|
hsv_enhanced = cv2.merge((h, s_enhanced, v))
|
||||||
|
|
||||||
|
# HSV 阈值范围(与 detect_circle_v3 保持一致)
|
||||||
|
lower_yellow = np.array([7, 80, 0])
|
||||||
|
upper_yellow = np.array([32, 255, 255])
|
||||||
|
mask_yellow = cv2.inRange(hsv_enhanced, lower_yellow, upper_yellow)
|
||||||
|
|
||||||
|
# 形态学操作,填充小孔洞
|
||||||
|
kernel = cv2.getStructuringElement(cv2.MORPH_ELLIPSE, (5, 5))
|
||||||
|
mask_yellow = cv2.morphologyEx(mask_yellow, cv2.MORPH_CLOSE, kernel)
|
||||||
|
logger_manager.logger.debug(f"after yellow mask detection")
|
||||||
|
|
||||||
|
# 计算边缘区域:扩展黄色区域,然后减去原始区域,得到边缘区域
|
||||||
|
mask_dilated = cv2.dilate(mask_yellow, kernel, iterations=2)
|
||||||
|
mask_edge = cv2.subtract(mask_dilated, mask_yellow) # 边缘区域
|
||||||
|
|
||||||
|
# 计算边缘区域的像素数量
|
||||||
|
edge_pixel_count = np.sum(mask_edge > 0)
|
||||||
|
logger_manager.logger.debug(f"edge pixel count: {edge_pixel_count}")
|
||||||
|
|
||||||
|
# 如果检测不到边缘区域,使用全局梯度作为后备方案
|
||||||
|
if edge_pixel_count < 100:
|
||||||
|
logger_manager.logger.debug(f"edge region too small, using global gradient")
|
||||||
|
# 使用 V 通道计算全局梯度
|
||||||
|
sobel_v_x = cv2.Sobel(v, cv2.CV_64F, 1, 0, ksize=3)
|
||||||
|
sobel_v_y = cv2.Sobel(v, cv2.CV_64F, 0, 1, ksize=3)
|
||||||
|
gradient = np.sqrt(sobel_v_x**2 + sobel_v_y**2)
|
||||||
|
sharpness_score = gradient.var()
|
||||||
|
logger_manager.logger.debug(f"global gradient variance: {sharpness_score:.2f}")
|
||||||
|
else:
|
||||||
|
# 在边缘区域计算梯度清晰度
|
||||||
|
# 使用 V(亮度)通道计算梯度,因为边缘在亮度上通常很明显
|
||||||
|
sobel_v_x = cv2.Sobel(v, cv2.CV_64F, 1, 0, ksize=3)
|
||||||
|
sobel_v_y = cv2.Sobel(v, cv2.CV_64F, 0, 1, ksize=3)
|
||||||
|
gradient = np.sqrt(sobel_v_x**2 + sobel_v_y**2)
|
||||||
|
|
||||||
|
# 只在边缘区域计算清晰度
|
||||||
|
edge_gradient = gradient[mask_edge > 0]
|
||||||
|
|
||||||
|
if len(edge_gradient) > 0:
|
||||||
|
# 计算边缘梯度的方差(清晰图像的边缘梯度变化大)
|
||||||
|
sharpness_score = edge_gradient.var()
|
||||||
|
# 也可以使用均值作为补充指标(清晰图像的边缘梯度均值也较大)
|
||||||
|
gradient_mean = edge_gradient.mean()
|
||||||
|
logger_manager.logger.debug(f"edge gradient: mean={gradient_mean:.2f}, var={sharpness_score:.2f}, pixels={len(edge_gradient)}")
|
||||||
|
else:
|
||||||
|
# 如果边缘区域没有有效梯度,使用全局梯度
|
||||||
|
sharpness_score = gradient.var()
|
||||||
|
logger_manager.logger.debug(f"no edge gradient, using global: {sharpness_score:.2f}")
|
||||||
|
|
||||||
|
# 保存调试图像(如果启用)
|
||||||
|
if save_debug_images:
|
||||||
|
try:
|
||||||
|
debug_dir = config.PHOTO_DIR
|
||||||
|
if debug_dir not in os.listdir("/root"):
|
||||||
|
try:
|
||||||
|
os.mkdir(debug_dir)
|
||||||
|
except:
|
||||||
|
pass
|
||||||
|
|
||||||
|
# 生成文件名
|
||||||
|
try:
|
||||||
|
all_images = [f for f in os.listdir(debug_dir) if f.endswith(('.bmp', '.jpg', '.jpeg'))]
|
||||||
|
img_count = len(all_images)
|
||||||
|
except:
|
||||||
|
img_count = 0
|
||||||
|
|
||||||
|
# 保存原始图像
|
||||||
|
img_orig = image.cv2image(img_cv, False, False)
|
||||||
|
orig_filename = f"{debug_dir}/sharpness_debug_orig_{img_count:04d}.bmp"
|
||||||
|
img_orig.save(orig_filename)
|
||||||
|
|
||||||
|
# # 保存边缘检测结果(可视化)
|
||||||
|
# # 创建可视化图像:原始图像 + 黄色区域 + 边缘区域
|
||||||
|
# debug_img = img_cv.copy()
|
||||||
|
# # 在黄色区域绘制绿色
|
||||||
|
# debug_img[mask_yellow > 0] = [0, 255, 0] # RGB格式,绿色
|
||||||
|
# # 在边缘区域绘制红色
|
||||||
|
# debug_img[mask_edge > 0] = [255, 0, 0] # RGB格式,红色
|
||||||
|
|
||||||
|
# debug_img_maix = image.cv2image(debug_img, False, False)
|
||||||
|
# debug_filename = f"{debug_dir}/sharpness_debug_edge_{img_count:04d}.bmp"
|
||||||
|
# debug_img_maix.save(debug_filename)
|
||||||
|
|
||||||
|
# logger = logger_manager.logger
|
||||||
|
# if logger:
|
||||||
|
# logger.info(f"[VISION] 保存调试图像: {orig_filename}, {debug_filename}")
|
||||||
|
except Exception as e:
|
||||||
|
logger = logger_manager.logger
|
||||||
|
if logger:
|
||||||
|
logger.warning(f"[VISION] 保存调试图像失败: {e}")
|
||||||
|
import traceback
|
||||||
|
logger.error(traceback.format_exc())
|
||||||
|
|
||||||
|
is_sharp = sharpness_score >= threshold
|
||||||
|
|
||||||
|
logger = logger_manager.logger
|
||||||
|
if logger:
|
||||||
|
logger.debug(f"[VISION] 清晰度检测: 分数={sharpness_score:.2f}, 边缘像素数={edge_pixel_count}, 是否清晰={is_sharp}, 阈值={threshold}")
|
||||||
|
|
||||||
|
return is_sharp, sharpness_score
|
||||||
|
except Exception as e:
|
||||||
|
logger = logger_manager.logger
|
||||||
|
if logger:
|
||||||
|
logger.error(f"[VISION] 清晰度检测失败: {e}")
|
||||||
|
import traceback
|
||||||
|
logger.error(traceback.format_exc())
|
||||||
|
# 出错时返回 False,避免使用模糊图像
|
||||||
|
return False, 0.0
|
||||||
|
|
||||||
|
def save_calibration_image(frame, laser_pos, photo_dir=None):
|
||||||
|
"""
|
||||||
|
保存激光校准图像(带标注)
|
||||||
|
在找到的激光点位置绘制圆圈,便于检查算法是否正确
|
||||||
|
|
||||||
|
Args:
|
||||||
|
frame: 原始图像帧
|
||||||
|
laser_pos: 找到的激光点坐标 (x, y)
|
||||||
|
photo_dir: 照片存储目录,如果为None则使用 config.PHOTO_DIR
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
str: 保存的文件路径,如果保存失败则返回 None
|
||||||
|
"""
|
||||||
|
# 检查是否启用图像保存
|
||||||
|
if not config.SAVE_IMAGE_ENABLED:
|
||||||
|
return None
|
||||||
|
|
||||||
|
if photo_dir is None:
|
||||||
|
photo_dir = config.PHOTO_DIR
|
||||||
|
|
||||||
|
try:
|
||||||
|
# 确保照片目录存在
|
||||||
|
try:
|
||||||
|
if photo_dir not in os.listdir("/root"):
|
||||||
|
os.mkdir(photo_dir)
|
||||||
|
except:
|
||||||
|
pass
|
||||||
|
|
||||||
|
# 生成文件名
|
||||||
|
try:
|
||||||
|
all_images = [f for f in os.listdir(photo_dir) if f.endswith(('.bmp', '.jpg', '.jpeg'))]
|
||||||
|
img_count = len(all_images)
|
||||||
|
except:
|
||||||
|
img_count = 0
|
||||||
|
|
||||||
|
x, y = laser_pos
|
||||||
|
filename = f"{photo_dir}/calibration_{int(x)}_{int(y)}_{img_count:04d}.bmp"
|
||||||
|
|
||||||
|
logger = logger_manager.logger
|
||||||
|
if logger:
|
||||||
|
logger.info(f"保存校准图像: {filename}, 激光点: ({x}, {y})")
|
||||||
|
|
||||||
|
# 转换图像为 OpenCV 格式以便绘制
|
||||||
|
img_cv = image.image2cv(frame, False, False)
|
||||||
|
|
||||||
|
# 绘制激光点圆圈(用绿色圆圈标出找到的激光点)
|
||||||
|
cv2.circle(img_cv, (int(x), int(y)), 10, (0, 255, 0), 2) # 外圈:绿色,半径10
|
||||||
|
cv2.circle(img_cv, (int(x), int(y)), 5, (0, 255, 0), 2) # 中圈:绿色,半径5
|
||||||
|
cv2.circle(img_cv, (int(x), int(y)), 2, (0, 255, 0), -1) # 中心点:绿色实心
|
||||||
|
|
||||||
|
# 可选:绘制十字线帮助定位
|
||||||
|
cv2.line(img_cv,
|
||||||
|
(int(x - 20), int(y)),
|
||||||
|
(int(x + 20), int(y)),
|
||||||
|
(0, 255, 0), 1) # 水平线
|
||||||
|
cv2.line(img_cv,
|
||||||
|
(int(x), int(y - 20)),
|
||||||
|
(int(x), int(y + 20)),
|
||||||
|
(0, 255, 0), 1) # 垂直线
|
||||||
|
|
||||||
|
# 转换回 MaixPy 图像格式并保存
|
||||||
|
result_img = image.cv2image(img_cv, False, False)
|
||||||
|
result_img.save(filename)
|
||||||
|
|
||||||
|
if logger:
|
||||||
|
logger.debug(f"校准图像已保存: {filename}")
|
||||||
|
|
||||||
|
return filename
|
||||||
|
except Exception as e:
|
||||||
|
logger = logger_manager.logger
|
||||||
|
if logger:
|
||||||
|
logger.error(f"保存校准图像失败: {e}")
|
||||||
|
import traceback
|
||||||
|
logger.error(traceback.format_exc())
|
||||||
|
return 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,会选择最接近激光点的目标
|
||||||
|
优化:
|
||||||
|
1. 缩图到 MAX_DET_DIM 后再做 HSV/形态学,最长边 640->320 可获得 ~4x 加速
|
||||||
|
2. 红色掩码在黄色轮廓循环外只计算一次,避免 N 次重复计算
|
||||||
|
3. img_cv 可由外部传入(与其他线程共享转换结果),为 None 时自动转换
|
||||||
|
Args:
|
||||||
|
frame: 图像帧(img_cv 为 None 时使用)
|
||||||
|
laser_point: 激光点坐标 (x, y),用于多目标场景下的目标选择
|
||||||
|
img_cv: 已转换的 numpy BGR/RGB 图像;不为 None 时跳过 image2cv 转换
|
||||||
|
Returns:
|
||||||
|
(result_img, best_center, best_radius, method, best_radius1, ellipse_params)
|
||||||
|
"""
|
||||||
|
if img_cv is None:
|
||||||
|
img_cv = image.image2cv(frame, False, False)
|
||||||
|
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
|
||||||
|
ellipse_params = None
|
||||||
|
|
||||||
|
logger.debug(f"[detect_circle_v3] step 1 fin {datetime.now()}")
|
||||||
|
|
||||||
|
# -- 2. HSV + 黄色掩码
|
||||||
|
hsv = cv2.cvtColor(img_det, cv2.COLOR_RGB2HSV)
|
||||||
|
h, s, v = cv2.split(hsv)
|
||||||
|
s = np.clip(s * 1.1, 0, 255).astype(np.uint8)
|
||||||
|
hsv = cv2.merge((h, s, v))
|
||||||
|
lower_yellow = np.array([7, 80, 0])
|
||||||
|
upper_yellow = np.array([32, 255, 255])
|
||||||
|
mask_yellow = cv2.inRange(hsv, lower_yellow, upper_yellow)
|
||||||
|
kernel = cv2.getStructuringElement(cv2.MORPH_ELLIPSE, (5, 5))
|
||||||
|
mask_yellow = cv2.morphologyEx(mask_yellow, cv2.MORPH_CLOSE, kernel)
|
||||||
|
|
||||||
|
logger.debug(f"[detect_circle_v3] step 2 fin {datetime.now()}")
|
||||||
|
|
||||||
|
# -- 3. 红色掩码:在循环外只算一次
|
||||||
|
mask_red = cv2.bitwise_or(
|
||||||
|
cv2.inRange(hsv, np.array([0, 80, 0]), np.array([10, 255, 255])),
|
||||||
|
cv2.inRange(hsv, np.array([170, 80, 0]), np.array([180, 255, 255])),
|
||||||
|
)
|
||||||
|
kernel_red = cv2.getStructuringElement(cv2.MORPH_ELLIPSE, (5, 5))
|
||||||
|
mask_red = cv2.morphologyEx(mask_red, cv2.MORPH_CLOSE, kernel_red)
|
||||||
|
contours_red, _ = cv2.findContours(mask_red, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE)
|
||||||
|
# 预先把红色轮廓筛选成 (center, radius) 列表,后续直接查表
|
||||||
|
red_candidates = []
|
||||||
|
for cnt_r in contours_red:
|
||||||
|
ar = cv2.contourArea(cnt_r)
|
||||||
|
if ar <= 50:
|
||||||
|
continue
|
||||||
|
pr = cv2.arcLength(cnt_r, True)
|
||||||
|
if pr <= 0 or (4 * np.pi * ar) / (pr * pr) <= 0.6:
|
||||||
|
continue
|
||||||
|
if len(cnt_r) >= 5:
|
||||||
|
(xr, yr), (wr, hr), _ = cv2.fitEllipse(cnt_r)
|
||||||
|
red_candidates.append({"center": (int(xr), int(yr)), "radius": int(min(wr, hr) / 2)})
|
||||||
|
else:
|
||||||
|
(xr, yr), rr = cv2.minEnclosingCircle(cnt_r)
|
||||||
|
red_candidates.append({"center": (int(xr), int(yr)), "radius": int(rr)})
|
||||||
|
|
||||||
|
logger.debug(f"[detect_circle_v3] step 3 fin {datetime.now()}")
|
||||||
|
|
||||||
|
# -- 4. 黄色轮廓循环(复用上面的红色候选列表)
|
||||||
|
contours_yellow, _ = cv2.findContours(mask_yellow, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE)
|
||||||
|
valid_targets = []
|
||||||
|
for cnt_yellow in contours_yellow:
|
||||||
|
area = cv2.contourArea(cnt_yellow)
|
||||||
|
if area <= 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"
|
||||||
|
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)
|
||||||
|
logger.debug(f"[detect_circle_v3] step 5 fin {datetime.now()}")
|
||||||
|
return result_img, best_center, best_radius, method, best_radius1, ellipse_params
|
||||||
|
|
||||||
|
def estimate_distance(pixel_radius):
|
||||||
|
"""根据像素半径估算实际距离(单位:米)"""
|
||||||
|
if not pixel_radius:
|
||||||
|
return 0.0
|
||||||
|
return (config.REAL_RADIUS_CM * config.FOCAL_LENGTH_PIX) / pixel_radius / 100.0
|
||||||
|
|
||||||
|
def estimate_pixel(physical_distance_cm, target_distance_m):
|
||||||
|
"""
|
||||||
|
根据物理距离和目标距离计算对应的像素偏移
|
||||||
|
|
||||||
|
Args:
|
||||||
|
physical_distance_cm: 物理世界中的距离(厘米),例如激光与摄像头的距离
|
||||||
|
target_distance_m: 目标距离(米),例如到靶心的距离
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
float: 对应的像素偏移
|
||||||
|
"""
|
||||||
|
if not target_distance_m or target_distance_m <= 0:
|
||||||
|
return 0.0
|
||||||
|
# 公式:像素偏移 = (物理距离_米) * 焦距_像素 / 目标距离_米
|
||||||
|
return (physical_distance_cm / 100.0) * config.FOCAL_LENGTH_PIX / target_distance_m
|
||||||
|
|
||||||
|
|
||||||
|
def _save_shot_image_impl(img_cv, center, radius, method, ellipse_params,
|
||||||
|
laser_point, distance_m, shot_id=None, photo_dir=None):
|
||||||
|
"""
|
||||||
|
内部实现:在 img_cv (numpy HWC RGB) 上绘制标注并保存。
|
||||||
|
由 save_shot_image(同步)和存图 worker(异步)调用。
|
||||||
|
"""
|
||||||
|
if not config.SAVE_IMAGE_ENABLED:
|
||||||
|
return None
|
||||||
|
if photo_dir is None:
|
||||||
|
photo_dir = config.PHOTO_DIR
|
||||||
|
try:
|
||||||
|
try:
|
||||||
|
if photo_dir not in os.listdir("/root"):
|
||||||
|
os.mkdir(photo_dir)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
x, y = laser_point
|
||||||
|
if shot_id:
|
||||||
|
# 之前是用 center/radius 判定 no_target;但三角形路径会返回 center=None(正常)
|
||||||
|
# 这里改为:只要 method 有值,就按 method 命名;否则才回退 no_target
|
||||||
|
method_str = (method or "").strip()
|
||||||
|
if method_str:
|
||||||
|
filename = f"{photo_dir}/shot_{shot_id}_{method_str}.bmp"
|
||||||
|
else:
|
||||||
|
filename = f"{photo_dir}/shot_{shot_id}_no_target.bmp"
|
||||||
|
else:
|
||||||
|
try:
|
||||||
|
all_images = [f for f in os.listdir(photo_dir) if f.endswith(('.bmp', '.jpg', '.jpeg'))]
|
||||||
|
img_count = len(all_images)
|
||||||
|
except Exception:
|
||||||
|
img_count = 0
|
||||||
|
if center is None or radius is None:
|
||||||
|
method_str = "no_target"
|
||||||
|
distance_str = "000"
|
||||||
|
else:
|
||||||
|
method_str = method or "unknown"
|
||||||
|
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"
|
||||||
|
|
||||||
|
logger = logger_manager.logger
|
||||||
|
if logger:
|
||||||
|
if shot_id:
|
||||||
|
logger.info(f"[VISION] 保存射箭图像,ID: {shot_id}, 文件名: {filename}")
|
||||||
|
if center and radius:
|
||||||
|
logger.info(f"结果 -> 圆心: {center}, 半径: {radius}, 方法: {method}")
|
||||||
|
if ellipse_params:
|
||||||
|
(ec, (ew, eh), ea) = ellipse_params
|
||||||
|
logger.info(f"椭圆 -> 中心: ({ec[0]:.1f}, {ec[1]:.1f}), 长轴: {max(ew, eh):.1f}, 短轴: {min(ew, eh):.1f}, 角度: {ea:.1f}°")
|
||||||
|
else:
|
||||||
|
logger.info(f"结果 -> 未检测到靶心,保存原始图像(激光点: ({x}, {y}))")
|
||||||
|
|
||||||
|
# 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_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), 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)
|
||||||
|
# ring_thickness = 1
|
||||||
|
# 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)), 2, laser_color, -1)
|
||||||
|
|
||||||
|
if center and radius:
|
||||||
|
cx, cy = center
|
||||||
|
if ellipse_params:
|
||||||
|
(ell_center, (width, height), angle) = ellipse_params
|
||||||
|
cx_ell, cy_ell = int(ell_center[0]), int(ell_center[1])
|
||||||
|
cv2.ellipse(img_cv, (cx_ell, cy_ell), (int(width / 2), int(height / 2)), angle, 0, 360, (0, 255, 0), 2)
|
||||||
|
cv2.circle(img_cv, (cx_ell, cy_ell), 3, (255, 0, 0), -1)
|
||||||
|
minor_length = min(width, height) / 2
|
||||||
|
minor_angle = angle + 90 if width >= height else angle
|
||||||
|
minor_angle_rad = math.radians(minor_angle)
|
||||||
|
dx_minor = minor_length * math.cos(minor_angle_rad)
|
||||||
|
dy_minor = minor_length * math.sin(minor_angle_rad)
|
||||||
|
pt1 = (int(cx_ell - dx_minor), int(cy_ell - dy_minor))
|
||||||
|
pt2 = (int(cx_ell + dx_minor), int(cy_ell + dy_minor))
|
||||||
|
cv2.line(img_cv, pt1, pt2, (0, 0, 255), 2)
|
||||||
|
else:
|
||||||
|
cv2.circle(img_cv, (cx, cy), radius, (0, 0, 255), 2)
|
||||||
|
cv2.circle(img_cv, (cx, cy), 2, (0, 0, 255), -1)
|
||||||
|
cv2.line(img_cv, (int(x), int(y)), (cx, cy), (255, 255, 0), 1)
|
||||||
|
|
||||||
|
out = image.cv2image(img_cv, False, False)
|
||||||
|
out.save(filename)
|
||||||
|
if logger:
|
||||||
|
if center and radius:
|
||||||
|
logger.debug(f"图像已保存(含靶心标注): {filename}")
|
||||||
|
else:
|
||||||
|
logger.debug(f"图像已保存(无靶心,含激光十字线): {filename}")
|
||||||
|
|
||||||
|
# 清理旧图片:如果目录下图片超过100张,删除最老的
|
||||||
|
try:
|
||||||
|
image_files = []
|
||||||
|
for f in os.listdir(photo_dir):
|
||||||
|
if f.endswith(('.bmp', '.jpg', '.jpeg')):
|
||||||
|
filepath = os.path.join(photo_dir, f)
|
||||||
|
try:
|
||||||
|
mtime = os.path.getmtime(filepath)
|
||||||
|
image_files.append((mtime, filepath, f))
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
from config import MAX_IMAGES
|
||||||
|
if len(image_files) > MAX_IMAGES:
|
||||||
|
image_files.sort(key=lambda x: x[0])
|
||||||
|
to_delete = len(image_files) - MAX_IMAGES
|
||||||
|
deleted_count = 0
|
||||||
|
for _, filepath, fname in image_files[:to_delete]:
|
||||||
|
try:
|
||||||
|
os.remove(filepath)
|
||||||
|
deleted_count += 1
|
||||||
|
if logger:
|
||||||
|
logger.debug(f"[VISION] 删除旧图片: {fname}")
|
||||||
|
except Exception as e:
|
||||||
|
if logger:
|
||||||
|
logger.warning(f"[VISION] 删除旧图片失败 {fname}: {e}")
|
||||||
|
if logger and deleted_count > 0:
|
||||||
|
logger.info(f"[VISION] 已清理 {deleted_count} 张旧图片,当前剩余 {MAX_IMAGES} 张")
|
||||||
|
except Exception as e:
|
||||||
|
if logger:
|
||||||
|
logger.warning(f"[VISION] 清理旧图片时出错(可忽略): {e}")
|
||||||
|
|
||||||
|
return filename
|
||||||
|
except Exception as e:
|
||||||
|
logger = logger_manager.logger
|
||||||
|
if logger:
|
||||||
|
logger.error(f"保存图像失败: {e}")
|
||||||
|
import traceback
|
||||||
|
logger.error(traceback.format_exc())
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _save_worker_loop():
|
||||||
|
"""存图 worker:从队列取任务并调用 _save_shot_image_impl。"""
|
||||||
|
while True:
|
||||||
|
try:
|
||||||
|
item = _save_queue.get()
|
||||||
|
if item is None:
|
||||||
|
break
|
||||||
|
_save_shot_image_impl(*item)
|
||||||
|
except Exception as e:
|
||||||
|
logger = logger_manager.logger
|
||||||
|
if logger:
|
||||||
|
logger.error(f"[VISION] 存图 worker 异常: {e}")
|
||||||
|
import traceback
|
||||||
|
logger.error(traceback.format_exc())
|
||||||
|
finally:
|
||||||
|
try:
|
||||||
|
_save_queue.task_done()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
def start_save_shot_worker():
|
||||||
|
"""启动存图 worker 线程(应在程序初始化时调用一次)。"""
|
||||||
|
global _save_worker_started
|
||||||
|
with _save_worker_lock:
|
||||||
|
if _save_worker_started:
|
||||||
|
return
|
||||||
|
_save_worker_started = True
|
||||||
|
t = threading.Thread(target=_save_worker_loop, daemon=True)
|
||||||
|
t.start()
|
||||||
|
logger = logger_manager.logger
|
||||||
|
if logger:
|
||||||
|
logger.info("[VISION] 存图 worker 线程已启动")
|
||||||
|
|
||||||
|
|
||||||
|
def enqueue_save_shot(result_img, center, radius, method, ellipse_params,
|
||||||
|
laser_point, distance_m, shot_id=None, photo_dir=None):
|
||||||
|
"""
|
||||||
|
将存图任务放入队列,由 worker 异步保存。主线程传入 result_img 的复制,不阻塞。
|
||||||
|
"""
|
||||||
|
if not config.SAVE_IMAGE_ENABLED:
|
||||||
|
return
|
||||||
|
if photo_dir is None:
|
||||||
|
photo_dir = config.PHOTO_DIR
|
||||||
|
try:
|
||||||
|
img_cv = image.image2cv(result_img, False, False)
|
||||||
|
img_copy = np.copy(img_cv)
|
||||||
|
except Exception as e:
|
||||||
|
logger = logger_manager.logger
|
||||||
|
if logger:
|
||||||
|
logger.error(f"[VISION] enqueue_save_shot 复制图像失败: {e}")
|
||||||
|
return
|
||||||
|
task = (img_copy, center, radius, method, ellipse_params, laser_point, distance_m, shot_id, photo_dir)
|
||||||
|
try:
|
||||||
|
_save_queue.put_nowait(task)
|
||||||
|
except queue.Full:
|
||||||
|
logger = logger_manager.logger
|
||||||
|
if logger:
|
||||||
|
logger.warning("[VISION] 存图队列已满,跳过本次保存")
|
||||||
|
|
||||||
|
|
||||||
|
def save_shot_image(result_img, center, radius, method, ellipse_params,
|
||||||
|
laser_point, distance_m, shot_id=None, photo_dir=None):
|
||||||
|
"""
|
||||||
|
保存射击图像(带标注)。同步调用,会阻塞。
|
||||||
|
主流程建议使用 enqueue_save_shot;此处保留供校准、测试等场景使用。
|
||||||
|
"""
|
||||||
|
if not config.SAVE_IMAGE_ENABLED:
|
||||||
|
return None
|
||||||
|
if photo_dir is None:
|
||||||
|
photo_dir = config.PHOTO_DIR
|
||||||
|
try:
|
||||||
|
img_cv = image.image2cv(result_img, False, False)
|
||||||
|
return _save_shot_image_impl(img_cv, center, radius, method, ellipse_params,
|
||||||
|
laser_point, distance_m, shot_id, photo_dir)
|
||||||
|
except Exception as e:
|
||||||
|
logger = logger_manager.logger
|
||||||
|
if logger:
|
||||||
|
logger.error(f"[VISION] save_shot_image 转换图像失败: {e}")
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def detect_target(frame, laser_point=None):
|
||||||
|
"""
|
||||||
|
统一的靶心检测接口,根据配置自动选择检测方法
|
||||||
|
|
||||||
|
Args:
|
||||||
|
frame: MaixPy图像帧
|
||||||
|
laser_point: 激光点坐标(可选)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
(result_img, center, radius, method, best_radius1, ellipse_params)
|
||||||
|
与detect_circle_v3保持相同的返回格式
|
||||||
|
"""
|
||||||
|
logger = logger_manager.logger
|
||||||
|
|
||||||
|
if config.USE_ARUCO:
|
||||||
|
# 使用ArUco检测
|
||||||
|
if logger:
|
||||||
|
logger.debug("[VISION] 使用ArUco标记检测靶心")
|
||||||
|
|
||||||
|
# 延迟导入以避免循环依赖
|
||||||
|
from aruco_detector import detect_target_with_aruco
|
||||||
|
return detect_target_with_aruco(frame, laser_point)
|
||||||
|
else:
|
||||||
|
# 使用传统黄色靶心检测
|
||||||
|
if logger:
|
||||||
|
logger.debug("[VISION] 使用传统黄色靶心检测")
|
||||||
|
return detect_circle_v3(frame, laser_point)
|
||||||
|
|
||||||
80
wifi.py
80
wifi.py
@@ -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:
|
||||||
|
|||||||
497
wifi_config_httpd.py
Normal file
497
wifi_config_httpd.py
Normal file
@@ -0,0 +1,497 @@
|
|||||||
|
#!/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 _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 可用,不启动热点配网")
|
||||||
|
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 可用,不启动热点配网")
|
||||||
|
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()
|
||||||
Reference in New Issue
Block a user