Compare commits
4 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 9d3826047e | |||
|
|
64722f4d73 | ||
|
|
575e690868 | ||
|
|
46508e4b31 |
8
.idea/.gitignore
generated
vendored
Normal file
8
.idea/.gitignore
generated
vendored
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
# 默认忽略的文件
|
||||||
|
/shelf/
|
||||||
|
/workspace.xml
|
||||||
|
# 基于编辑器的 HTTP 客户端请求
|
||||||
|
/httpRequests/
|
||||||
|
# Datasource local storage ignored files
|
||||||
|
/dataSources/
|
||||||
|
/dataSources.local.xml
|
||||||
1
.idea/.name
generated
Normal file
1
.idea/.name
generated
Normal file
@@ -0,0 +1 @@
|
|||||||
|
network.py
|
||||||
7
.idea/archery.iml
generated
Normal file
7
.idea/archery.iml
generated
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<module version="4">
|
||||||
|
<component name="PyDocumentationSettings">
|
||||||
|
<option name="format" value="PLAIN" />
|
||||||
|
<option name="myDocStringFormat" value="Plain" />
|
||||||
|
</component>
|
||||||
|
</module>
|
||||||
6
.idea/inspectionProfiles/profiles_settings.xml
generated
Normal file
6
.idea/inspectionProfiles/profiles_settings.xml
generated
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
<component name="InspectionProjectProfileManager">
|
||||||
|
<settings>
|
||||||
|
<option name="USE_PROJECT_PROFILE" value="false" />
|
||||||
|
<version value="1.0" />
|
||||||
|
</settings>
|
||||||
|
</component>
|
||||||
7
.idea/misc.xml
generated
Normal file
7
.idea/misc.xml
generated
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<project version="4">
|
||||||
|
<component name="Black">
|
||||||
|
<option name="sdkName" value="Python 3.13 virtualenv at H:\iot\racingiot_v1\.venv" />
|
||||||
|
</component>
|
||||||
|
<component name="ProjectRootManager" version="2" project-jdk-name="maixcam" project-jdk-type="Python SDK" />
|
||||||
|
</project>
|
||||||
6
.idea/vcs.xml
generated
Normal file
6
.idea/vcs.xml
generated
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<project version="4">
|
||||||
|
<component name="VcsDirectoryMappings">
|
||||||
|
<mapping directory="" vcs="Git" />
|
||||||
|
</component>
|
||||||
|
</project>
|
||||||
1
Untitled
Normal file
1
Untitled
Normal file
@@ -0,0 +1 @@
|
|||||||
|
v1.2.15.1] [ERROR] main.py:416 - [MAIN] 显示异常: 'LaserManager' object has no attribute 'remote_detect_tick'
|
||||||
BIN
__pycache__/version.cpython-312.pyc
Normal file
BIN
__pycache__/version.cpython-312.pyc
Normal file
Binary file not shown.
12
adc.py
12
adc.py
@@ -4,12 +4,12 @@ from maix import time
|
|||||||
a = adc.ADC(0, adc.RES_BIT_12)
|
a = adc.ADC(0, adc.RES_BIT_12)
|
||||||
|
|
||||||
while True:
|
while True:
|
||||||
# raw_data = a.read()
|
raw_data = a.read()
|
||||||
# print(f"ADC raw data:{raw_data}")
|
print(f"ADC raw data:{raw_data}")
|
||||||
# if raw_data > 2450:
|
if raw_data > 2450:
|
||||||
# print(f"ADC raw data:{raw_data}")
|
print(f"ADC raw data:{raw_data}")
|
||||||
# elif raw_data < 2000:
|
elif raw_data < 2000:
|
||||||
# print(f"ADC raw data:{raw_data}")
|
print(f"ADC raw data:{raw_data}")
|
||||||
time.sleep_ms(1)
|
time.sleep_ms(1)
|
||||||
|
|
||||||
vol = int(a.read_vol() * 10) / 10
|
vol = int(a.read_vol() * 10) / 10
|
||||||
|
|||||||
5
app.yaml
5
app.yaml
@@ -1,6 +1,6 @@
|
|||||||
id: t11
|
id: t11
|
||||||
name: t11
|
name: t11
|
||||||
version: 2.15.15
|
version: 2.1.1
|
||||||
author: t11
|
author: t11
|
||||||
icon: ''
|
icon: ''
|
||||||
desc: t11
|
desc: t11
|
||||||
@@ -14,17 +14,16 @@ files:
|
|||||||
- cameraParameters.xml
|
- cameraParameters.xml
|
||||||
- config.py
|
- config.py
|
||||||
- hardware.py
|
- hardware.py
|
||||||
- laser_detector.py
|
|
||||||
- laser_manager.py
|
- laser_manager.py
|
||||||
- logger_manager.py
|
- logger_manager.py
|
||||||
- main.py
|
- main.py
|
||||||
- model_270139.cvimodel
|
- model_270139.cvimodel
|
||||||
- model_270139.mud
|
- model_270139.mud
|
||||||
- network.py
|
- network.py
|
||||||
- ota_curl.sh
|
|
||||||
- ota_manager.py
|
- ota_manager.py
|
||||||
- power.py
|
- power.py
|
||||||
- server.pem
|
- server.pem
|
||||||
|
- set_autostart.py
|
||||||
- shoot_manager.py
|
- shoot_manager.py
|
||||||
- shot_id_generator.py
|
- shot_id_generator.py
|
||||||
- target_roi_yolo.py
|
- target_roi_yolo.py
|
||||||
|
|||||||
37
config.py
37
config.py
@@ -106,6 +106,14 @@ DEFAULT_LASER_POINT = (320, 245) # 默认激光中心点
|
|||||||
HARDCODE_LASER_POINT = True # 是否使用硬编码的激光点(True=使用硬编码值,False=使用校准值)
|
HARDCODE_LASER_POINT = True # 是否使用硬编码的激光点(True=使用硬编码值,False=使用校准值)
|
||||||
HARDCODE_LASER_POINT_VALUE = (320, 296) # 硬编码的激光点坐标(315, 245) # # 硬编码的激光点坐标 (x, y)
|
HARDCODE_LASER_POINT_VALUE = (320, 296) # 硬编码的激光点坐标(315, 245) # # 硬编码的激光点坐标 (x, y)
|
||||||
|
|
||||||
|
# 远程激光点识别(TCP cmd=200):画面内找红点,稳定 N 秒且无明显跳动后上报坐标
|
||||||
|
LASER_REMOTE_DETECT_STABLE_SEC = 3.0 # 连续稳定时长(秒)
|
||||||
|
LASER_REMOTE_DETECT_MAX_MOVE_PX = 12.0 # 窗口内最大位移超过此值视为大幅移动,重新计时
|
||||||
|
LASER_REMOTE_DETECT_SAMPLE_MS = 80 # 采样间隔
|
||||||
|
LASER_REMOTE_DETECT_MIN_SAMPLES = 8 # 判定稳定前窗口内最少样本数
|
||||||
|
LASER_REMOTE_DETECT_WARMUP_MS = 500 # cmd=200 开激光后等待稳定再采样
|
||||||
|
# 远程识别会话无总超时:cmd=200 启动后持续检测并上报,直至 cmd=201 停止
|
||||||
|
|
||||||
# 激光点检测配置
|
# 激光点检测配置
|
||||||
LASER_DETECTION_THRESHOLD = 140 # 红色通道阈值(默认120,可调整,范围建议:100-150)
|
LASER_DETECTION_THRESHOLD = 140 # 红色通道阈值(默认120,可调整,范围建议:100-150)
|
||||||
LASER_RED_RATIO = 1.5 # 红色相对于绿色/蓝色的倍数要求(默认1.5,可调整,范围建议:1.3-2.0)
|
LASER_RED_RATIO = 1.5 # 红色相对于绿色/蓝色的倍数要求(默认1.5,可调整,范围建议:1.3-2.0)
|
||||||
@@ -134,7 +142,7 @@ IMAGE_CENTER_Y = 240 # 图像中心 Y 坐标
|
|||||||
# ==================== 三角形四角标记:单应性偏移 + PnP 估距 ====================
|
# ==================== 三角形四角标记:单应性偏移 + PnP 估距 ====================
|
||||||
# 依赖 cameraParameters.xml(相机内参)与 triangle_positions.json(四角物方坐标,厘米或毫米见 JSON 约定)。
|
# 依赖 cameraParameters.xml(相机内参)与 triangle_positions.json(四角物方坐标,厘米或毫米见 JSON 约定)。
|
||||||
# 部署时请把这两个文件放到 APP_DIR(与 main 同应用目录),或改下面路径为设备上的实际绝对路径。
|
# 部署时请把这两个文件放到 APP_DIR(与 main 同应用目录),或改下面路径为设备上的实际绝对路径。
|
||||||
USE_TRIANGLE_OFFSET = False # False 时仅走黄心圆/椭圆 + 半径估距,不使用三角形路径
|
USE_TRIANGLE_OFFSET = True # False 时仅走黄心圆/椭圆 + 半径估距,不使用三角形路径
|
||||||
CAMERA_CALIB_XML = APP_DIR + "/cameraParameters.xml"
|
CAMERA_CALIB_XML = APP_DIR + "/cameraParameters.xml"
|
||||||
TRIANGLE_POSITIONS_JSON = APP_DIR + "/triangle_positions.json"
|
TRIANGLE_POSITIONS_JSON = APP_DIR + "/triangle_positions.json"
|
||||||
# 检测到的三角形边长在图像中的像素范围,分辨率或靶纸占比变化时可微调
|
# 检测到的三角形边长在图像中的像素范围,分辨率或靶纸占比变化时可微调
|
||||||
@@ -144,6 +152,13 @@ TRIANGLE_SIZE_RANGE = (8, 500)
|
|||||||
# 如果射箭距离很固定,可设具体范围(如 min=2.5, max=6.0)作为额外保险
|
# 如果射箭距离很固定,可设具体范围(如 min=2.5, max=6.0)作为额外保险
|
||||||
TRIANGLE_DISTANCE_MIN_M = 0.0 # 0=不启用下限检查
|
TRIANGLE_DISTANCE_MIN_M = 0.0 # 0=不启用下限检查
|
||||||
TRIANGLE_DISTANCE_MAX_M = 0.0 # 0=不启用上限检查
|
TRIANGLE_DISTANCE_MAX_M = 0.0 # 0=不启用上限检查
|
||||||
|
# 三角形方向校验:四角黑三角应为 ◤ ◥ / ◣ ◢,即三角形从外角指向靶心;用于过滤相邻靶混入/跨靶组合
|
||||||
|
TRIANGLE_DIRECTION_VALIDATE_ENABLE = False
|
||||||
|
TRIANGLE_DIRECTION_MIN_PASS = 3 # 至少多少个真实三角方向正确才认为该组有效;3点补全时推荐3,误检多可设2
|
||||||
|
TRIANGLE_DIRECTION_DOT_MIN = 0.0 # 方向点积阈值;0=只要求同向半平面,0.35≈夹角<70°,0.5≈夹角<60°
|
||||||
|
TRIANGLE_DIRECTION_TO_CENTER_DOT_MIN = 0.35 # 必须指向候选靶心;0.35≈夹角<70°,用于过滤相邻靶混入
|
||||||
|
TRIANGLE_CENTER_DISTANCE_VALIDATE_ENABLE = True # 四角三角到候选靶心距离需近似一致,过滤跨靶组合
|
||||||
|
TRIANGLE_CENTER_DISTANCE_RATIO_TOL = 0.45 # (max_dist-min_dist)/mean_dist 最大允许值;越小越严格
|
||||||
# 三角形检测兜底增强:CLAHE(更鲁棒但更慢)。颜色阈值修复后通常不需要,保持关闭以优先速度。
|
# 三角形检测兜底增强:CLAHE(更鲁棒但更慢)。颜色阈值修复后通常不需要,保持关闭以优先速度。
|
||||||
TRIANGLE_ENABLE_CLAHE_FALLBACK = False
|
TRIANGLE_ENABLE_CLAHE_FALLBACK = False
|
||||||
# 三角形检测调试:保存 Otsu 二值化图像(临时调试用,定位后关闭)
|
# 三角形检测调试:保存 Otsu 二值化图像(临时调试用,定位后关闭)
|
||||||
@@ -169,6 +184,7 @@ TRIANGLE_SHAPE_COS_TOLERANCE = 0.25 # 直角余弦绝对值上限(原 0.20
|
|||||||
# 建议设为实测最坏耗时的 1.2 倍;超时后圆心检测仍会并行跑完,跑完后若三角形已结束则优先用三角形。
|
# 建议设为实测最坏耗时的 1.2 倍;超时后圆心检测仍会并行跑完,跑完后若三角形已结束则优先用三角形。
|
||||||
TRIANGLE_TIMEOUT_MS = 1000
|
TRIANGLE_TIMEOUT_MS = 1000
|
||||||
# True=打印各阶段耗时(ms),用于定位瓶颈;稳定后可 False 减少日志
|
# True=打印各阶段耗时(ms),用于定位瓶颈;稳定后可 False 减少日志
|
||||||
|
ARCHERY_TIMING_ENABLE = False # 总开关:False 关闭所有算法耗时统计(shoot_manager + triangle_target + vision)
|
||||||
TRIANGLE_TIMING_LOG = True
|
TRIANGLE_TIMING_LOG = True
|
||||||
# True=Stage2 每个子框内传统三角失败时打一条统计(Otsu/Adaptive 下轮廓数与各拒绝原因计数)
|
# True=Stage2 每个子框内传统三角失败时打一条统计(Otsu/Adaptive 下轮廓数与各拒绝原因计数)
|
||||||
TRIANGLE_LOG_STAGE2_PATCH_REJECT = True
|
TRIANGLE_LOG_STAGE2_PATCH_REJECT = True
|
||||||
@@ -256,11 +272,15 @@ TRIANGLE_CROP_ROI_MIN_SIDE_PX = 64
|
|||||||
# 射箭保存图 / 预览上绘制 YOLO 靶环 ROI 矩形 (x0,y0,x1,y1),核对是否裁准;不需要时改 False
|
# 射箭保存图 / 预览上绘制 YOLO 靶环 ROI 矩形 (x0,y0,x1,y1),核对是否裁准;不需要时改 False
|
||||||
TRIANGLE_YOLO_DRAW_ROI_ON_SHOT = True
|
TRIANGLE_YOLO_DRAW_ROI_ON_SHOT = True
|
||||||
# 物方采样调试:以靶心为中心,取半径 15cm 的圆周样本点,用于黑/白颜色对比
|
# 物方采样调试:以靶心为中心,取半径 15cm 的圆周样本点,用于黑/白颜色对比
|
||||||
|
TRIANGLE_SAMPLE_ENABLE = True
|
||||||
|
TRIANGLE_SAMPLE_TIMING_ENABLE = True # 仅统计物方采样耗时(其他 timing 可关)
|
||||||
TRIANGLE_SAMPLE_RADIUS_CM = 15.0
|
TRIANGLE_SAMPLE_RADIUS_CM = 15.0
|
||||||
TRIANGLE_SAMPLE_ANGLES_DEG = (0, 90, 180, 270)
|
TRIANGLE_SAMPLE_ANGLES_DEG = (0, 90, 180, 270)
|
||||||
TRIANGLE_SAMPLE_PATCH_HALF_PX = 2
|
TRIANGLE_SAMPLE_PATCH_HALF_PX = 2
|
||||||
|
# 物方采样判断黑白阈值(R/G/B 均小于此值视为黑);40cm 黑靶在靶面位置全黑,20cm 白靶则 R/G/B 偏高
|
||||||
|
TRIANGLE_SAMPLE_BLACK_THRESH = 30.0
|
||||||
# 开机阶段预加载 YOLO detector;detect 使用 dual_buff=False,避免返回上一帧结果。
|
# 开机阶段预加载 YOLO detector;detect 使用 dual_buff=False,避免返回上一帧结果。
|
||||||
TRIANGLE_YOLO_PRELOAD_ON_BOOT = False
|
TRIANGLE_YOLO_PRELOAD_ON_BOOT = True
|
||||||
|
|
||||||
# ── 第二段 YOLO:仅在 Stage1 裁切出的靶环图上推理(与合成 stage2 训练数据一致)→ 子框内传统算法取直角点 ──
|
# ── 第二段 YOLO:仅在 Stage1 裁切出的靶环图上推理(与合成 stage2 训练数据一致)→ 子框内传统算法取直角点 ──
|
||||||
# Stage1 靶环裁切内如何找黑三角标记(对比耗时时可切换):
|
# Stage1 靶环裁切内如何找黑三角标记(对比耗时时可切换):
|
||||||
@@ -308,21 +328,16 @@ LASER_COLOR = (0, 255, 0) # RGB颜色
|
|||||||
LASER_THICKNESS = 1
|
LASER_THICKNESS = 1
|
||||||
LASER_LENGTH = 2
|
LASER_LENGTH = 2
|
||||||
|
|
||||||
# ==================== 队列大小限制(防止内存泄漏) ====================
|
|
||||||
MAX_SEND_QUEUE_SIZE = 500 # 发送队列上限
|
|
||||||
MAX_TCP_PAYLOADS = 500 # AT TCP 载荷缓存上限
|
|
||||||
MAX_HTTP_EVENTS = 200 # AT HTTP 事件缓存上限
|
|
||||||
LOG_QUEUE_MAXSIZE = 10000 # 日志队列上限
|
|
||||||
MAX_CMD_THREADS = 10 # 并发命令线程上限(防止服务器下发命令时无限创建线程)
|
|
||||||
|
|
||||||
# ==================== 图像保存配置 ====================
|
# ==================== 图像保存配置 ====================
|
||||||
SAVE_IMAGE_ENABLED = False # 是否保存图像(True=保存,False=不保存)
|
SAVE_IMAGE_ENABLED = True # 是否保存图像(True=保存,False=不保存)
|
||||||
|
SAVE_RAW_SHOT_IMAGE_ENABLED = False # 是否额外保存射箭原图;可通过 TCP cmd=46 动态开关
|
||||||
|
VISION_TIMING_ENABLE = True # 视觉圆检测耗时统计(detect_circle_v3 内部各步骤耗时)
|
||||||
PHOTO_DIR = "/root/phot" # 照片存储目录
|
PHOTO_DIR = "/root/phot" # 照片存储目录
|
||||||
MAX_IMAGES = 1000
|
MAX_IMAGES = 1000
|
||||||
# Stage2 调试目录(默认 PHOTO_DIR/stage2_roi)内 JPEG 最多保留张数;None 表示与 MAX_IMAGES 相同
|
# Stage2 调试目录(默认 PHOTO_DIR/stage2_roi)内 JPEG 最多保留张数;None 表示与 MAX_IMAGES 相同
|
||||||
TRIANGLE_BLACK_YOLO_STAGE2_ROI_MAX_IMAGES = None
|
TRIANGLE_BLACK_YOLO_STAGE2_ROI_MAX_IMAGES = None
|
||||||
|
|
||||||
SHOW_CAMERA_PHOTO_WHILE_SHOOTING = False # 是否在拍摄时显示摄像头图像(True=显示,False=不显示),建议在连着USB测试过程中打开
|
SHOW_CAMERA_PHOTO_WHILE_SHOOTING = True # 是否在拍摄时显示摄像头图像(True=显示,False=不显示),建议在连着USB测试过程中打开
|
||||||
|
|
||||||
# ==================== OTA配置 ====================
|
# ==================== OTA配置 ====================
|
||||||
MAX_BACKUPS = 5
|
MAX_BACKUPS = 5
|
||||||
|
|||||||
@@ -1,248 +0,0 @@
|
|||||||
from maix import image, time
|
|
||||||
from logger_manager import logger_manager
|
|
||||||
from camera_manager import camera_manager
|
|
||||||
|
|
||||||
_USE_CV = False
|
|
||||||
try:
|
|
||||||
import cv2
|
|
||||||
import numpy as np
|
|
||||||
|
|
||||||
_USE_CV = True
|
|
||||||
except ImportError:
|
|
||||||
pass
|
|
||||||
|
|
||||||
WIDTH = 640
|
|
||||||
HEIGHT = 480
|
|
||||||
THRESHOLD = 100
|
|
||||||
RED_RATIO = 1.5
|
|
||||||
SEARCH_RADIUS = 80
|
|
||||||
TRACK_RADIUS = 30
|
|
||||||
MIN_PIXELS = 3
|
|
||||||
COARSE_STEP = 2
|
|
||||||
STABLE_COUNT = 2
|
|
||||||
MAX_SKIP_FRAMES = 5
|
|
||||||
|
|
||||||
# Temporal smoothing
|
|
||||||
_EMA_ALPHA = 0.35
|
|
||||||
_GATE_PX = 10
|
|
||||||
_FRAME_INTERVAL_MS = 50
|
|
||||||
|
|
||||||
_prev_smoothed = None
|
|
||||||
|
|
||||||
|
|
||||||
def _red_weighted_centroid(r_ch, g_ch, b_ch, mask, x0, y0):
|
|
||||||
y_ids, x_ids = np.where(mask)
|
|
||||||
if len(y_ids) == 0:
|
|
||||||
return None
|
|
||||||
r_vals = r_ch[y_ids, x_ids].astype(np.float64)
|
|
||||||
g_vals = g_ch[y_ids, x_ids].astype(np.float64)
|
|
||||||
b_vals = b_ch[y_ids, x_ids].astype(np.float64)
|
|
||||||
w = r_vals - np.maximum(g_vals, b_vals)
|
|
||||||
w = np.clip(w, 0, None)
|
|
||||||
w = w * w
|
|
||||||
total_w = w.sum()
|
|
||||||
if total_w < 1e-6:
|
|
||||||
return None
|
|
||||||
cx = (x_ids.astype(np.float64) * w).sum() / total_w + x0
|
|
||||||
cy = (y_ids.astype(np.float64) * w).sum() / total_w + y0
|
|
||||||
return (float(cx), float(cy))
|
|
||||||
|
|
||||||
|
|
||||||
def find_ellipse(img_cv, cx, cy, roi_r, th, ratio):
|
|
||||||
x1 = max(0, cx - roi_r)
|
|
||||||
x2 = min(WIDTH, cx + roi_r)
|
|
||||||
y1 = max(0, cy - roi_r)
|
|
||||||
y2 = min(HEIGHT, cy + roi_r)
|
|
||||||
roi = img_cv[y1:y2, x1:x2]
|
|
||||||
if roi.size == 0:
|
|
||||||
return None
|
|
||||||
r = roi[:, :, 0].astype(np.int32)
|
|
||||||
g = roi[:, :, 1].astype(np.int32)
|
|
||||||
b = roi[:, :, 2].astype(np.int32)
|
|
||||||
mask = (r > th) & (r > g * ratio) & (r > b * ratio)
|
|
||||||
oe = (r > 200) & (g > 200) & (b > 200) & (r >= g) & (r >= b) & ((r - g) > 10) & ((r - b) > 10)
|
|
||||||
combined = (mask | oe).astype(np.uint8) * 255
|
|
||||||
contours, _ = cv2.findContours(combined, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE)
|
|
||||||
if not contours:
|
|
||||||
return None
|
|
||||||
largest = max(contours, key=cv2.contourArea)
|
|
||||||
if cv2.contourArea(largest) < 5:
|
|
||||||
return None
|
|
||||||
cnt = largest.copy()
|
|
||||||
for pt in cnt:
|
|
||||||
pt[0][0] += x1
|
|
||||||
pt[0][1] += y1
|
|
||||||
ellipse_valid = len(cnt) >= 5
|
|
||||||
if ellipse_valid:
|
|
||||||
(ex, ey), (ew, eh), ang = cv2.fitEllipse(cnt)
|
|
||||||
mask_ellipse = np.zeros((HEIGHT, WIDTH), dtype=np.uint8)
|
|
||||||
cv2.ellipse(mask_ellipse, (int(ex), int(ey)), (int(ew / 2), int(eh / 2)), ang, 0, 360, 255, -1)
|
|
||||||
return _red_weighted_centroid(
|
|
||||||
img_cv[:, :, 0], img_cv[:, :, 1], img_cv[:, :, 2],
|
|
||||||
mask_ellipse > 0, 0, 0
|
|
||||||
)
|
|
||||||
M = cv2.moments(cnt)
|
|
||||||
if M["m00"] > 0:
|
|
||||||
return (float(M["m10"] / M["m00"]), float(M["m01"] / M["m00"]))
|
|
||||||
return None
|
|
||||||
|
|
||||||
|
|
||||||
def is_red(r, g, b, th, ratio):
|
|
||||||
if r > th and r > g * ratio and r > b * ratio:
|
|
||||||
return True
|
|
||||||
if (r > 200 and g > 200 and b > 200 and r >= g and r >= b
|
|
||||||
and (r - g) > 10 and (r - b) > 10):
|
|
||||||
return True
|
|
||||||
return False
|
|
||||||
|
|
||||||
|
|
||||||
def find_brightest_bytes(frame, cx, cy, roi_r, th, ratio):
|
|
||||||
x1 = max(0, cx - roi_r)
|
|
||||||
x2 = min(WIDTH, cx + roi_r)
|
|
||||||
y1 = max(0, cy - roi_r)
|
|
||||||
y2 = min(HEIGHT, cy + roi_r)
|
|
||||||
data = frame.to_bytes()
|
|
||||||
|
|
||||||
best_score = 0
|
|
||||||
best_x = (x1 + x2) // 2
|
|
||||||
best_y = (y1 + y2) // 2
|
|
||||||
found_any = False
|
|
||||||
for y in range(y1, y2, COARSE_STEP):
|
|
||||||
for x in range(x1, x2, COARSE_STEP):
|
|
||||||
idx = (y * WIDTH + x) * 3
|
|
||||||
r = data[idx]
|
|
||||||
g = data[idx + 1]
|
|
||||||
b = data[idx + 2]
|
|
||||||
if is_red(r, g, b, th, ratio):
|
|
||||||
score = r + g + b
|
|
||||||
dx = x - cx
|
|
||||||
dy = y - cy
|
|
||||||
dist_decay = max(0.5, 1.0 - ((dx * dx + dy * dy) ** 0.5 / roi_r) * 0.5)
|
|
||||||
score *= dist_decay
|
|
||||||
if score > best_score:
|
|
||||||
best_score = score
|
|
||||||
best_x = x
|
|
||||||
best_y = y
|
|
||||||
found_any = True
|
|
||||||
|
|
||||||
if not found_any:
|
|
||||||
return None
|
|
||||||
|
|
||||||
sf = 4
|
|
||||||
fx1 = max(x1, best_x - sf)
|
|
||||||
fx2 = min(x2, best_x + sf + 1)
|
|
||||||
fy1 = max(y1, best_y - sf)
|
|
||||||
fy2 = min(y2, best_y + sf + 1)
|
|
||||||
|
|
||||||
sum_x = 0.0
|
|
||||||
sum_y = 0.0
|
|
||||||
total_w = 0.0
|
|
||||||
count = 0
|
|
||||||
for y in range(fy1, fy2):
|
|
||||||
for x in range(fx1, fx2):
|
|
||||||
idx = (y * WIDTH + x) * 3
|
|
||||||
r = data[idx]
|
|
||||||
g = data[idx + 1]
|
|
||||||
b = data[idx + 2]
|
|
||||||
if is_red(r, g, b, th, ratio):
|
|
||||||
w = r + g + b
|
|
||||||
sum_x += x * w
|
|
||||||
sum_y += y * w
|
|
||||||
total_w += w
|
|
||||||
count += 1
|
|
||||||
|
|
||||||
if count < MIN_PIXELS:
|
|
||||||
return (float(best_x), float(best_y))
|
|
||||||
|
|
||||||
return (float(sum_x / total_w), float(sum_y / total_w))
|
|
||||||
|
|
||||||
|
|
||||||
def _ema_filter(pos, alpha=_EMA_ALPHA):
|
|
||||||
global _prev_smoothed
|
|
||||||
if _prev_smoothed is None:
|
|
||||||
_prev_smoothed = pos
|
|
||||||
return pos
|
|
||||||
sx = alpha * pos[0] + (1 - alpha) * _prev_smoothed[0]
|
|
||||||
sy = alpha * pos[1] + (1 - alpha) * _prev_smoothed[1]
|
|
||||||
_prev_smoothed = (sx, sy)
|
|
||||||
return _prev_smoothed
|
|
||||||
|
|
||||||
|
|
||||||
def _gated(pos, gate_px=_GATE_PX):
|
|
||||||
global _prev_smoothed
|
|
||||||
if _prev_smoothed is None:
|
|
||||||
return True
|
|
||||||
dx = pos[0] - _prev_smoothed[0]
|
|
||||||
dy = pos[1] - _prev_smoothed[1]
|
|
||||||
return (dx * dx + dy * dy) <= gate_px * gate_px
|
|
||||||
|
|
||||||
|
|
||||||
def get_stable_laser_point(timeout_ms=15000, stable_count=STABLE_COUNT):
|
|
||||||
global _prev_smoothed
|
|
||||||
_prev_smoothed = None
|
|
||||||
try:
|
|
||||||
last_raw = None
|
|
||||||
stable = 0
|
|
||||||
start = time.ticks_ms()
|
|
||||||
cx, cy = WIDTH // 2, HEIGHT // 2
|
|
||||||
track_count = 0
|
|
||||||
skip_count = 0
|
|
||||||
while True:
|
|
||||||
if abs(time.ticks_diff(time.ticks_ms(), start)) > timeout_ms:
|
|
||||||
_prev_smoothed = None
|
|
||||||
return None
|
|
||||||
frame = camera_manager.read_frame()
|
|
||||||
if frame is None:
|
|
||||||
time.sleep_ms(10)
|
|
||||||
continue
|
|
||||||
|
|
||||||
if track_count > 0 and _prev_smoothed is not None:
|
|
||||||
search_cx = int(_prev_smoothed[0])
|
|
||||||
search_cy = int(_prev_smoothed[1])
|
|
||||||
search_r = TRACK_RADIUS
|
|
||||||
else:
|
|
||||||
search_cx = cx
|
|
||||||
search_cy = cy
|
|
||||||
search_r = SEARCH_RADIUS
|
|
||||||
|
|
||||||
pos_bright = find_brightest_bytes(frame, search_cx, search_cy, search_r, THRESHOLD, RED_RATIO)
|
|
||||||
pos = pos_bright
|
|
||||||
if _USE_CV:
|
|
||||||
img_cv = image.image2cv(frame, False, False)
|
|
||||||
pos_ellipse = find_ellipse(img_cv, search_cx, search_cy, search_r, THRESHOLD, RED_RATIO)
|
|
||||||
if pos_ellipse is not None:
|
|
||||||
pos = pos_ellipse
|
|
||||||
|
|
||||||
if pos is not None:
|
|
||||||
skip_count = 0
|
|
||||||
track_count += 1
|
|
||||||
filtered = _ema_filter(pos)
|
|
||||||
if last_raw is not None:
|
|
||||||
dx = abs(filtered[0] - last_raw[0])
|
|
||||||
dy = abs(filtered[1] - last_raw[1])
|
|
||||||
if dx <= 2 and dy <= 2:
|
|
||||||
stable += 1
|
|
||||||
else:
|
|
||||||
stable = 1
|
|
||||||
else:
|
|
||||||
stable = 1
|
|
||||||
last_raw = filtered
|
|
||||||
if logger_manager.logger:
|
|
||||||
logger_manager.logger.info(f"pos:{pos},filtered:{filtered},stable:{stable}")
|
|
||||||
if stable >= stable_count:
|
|
||||||
result = (int(filtered[0]), int(filtered[1]))
|
|
||||||
_prev_smoothed = None
|
|
||||||
return result
|
|
||||||
else:
|
|
||||||
skip_count += 1
|
|
||||||
if logger_manager.logger:
|
|
||||||
logger_manager.logger.info(f"find_brightest_bytes None, skip={skip_count}, track={track_count}, search_center=({search_cx},{search_cy}), search_r={search_r}")
|
|
||||||
if skip_count > MAX_SKIP_FRAMES:
|
|
||||||
_prev_smoothed = None
|
|
||||||
track_count = 0
|
|
||||||
stable = 0
|
|
||||||
last_raw = None
|
|
||||||
|
|
||||||
time.sleep_ms(_FRAME_INTERVAL_MS)
|
|
||||||
finally:
|
|
||||||
_prev_smoothed = None
|
|
||||||
461
laser_manager.py
461
laser_manager.py
@@ -6,6 +6,7 @@
|
|||||||
"""
|
"""
|
||||||
import _thread
|
import _thread
|
||||||
import json
|
import json
|
||||||
|
import math
|
||||||
import os
|
import os
|
||||||
import binascii
|
import binascii
|
||||||
from maix import time
|
from maix import time
|
||||||
@@ -34,9 +35,13 @@ class LaserManager:
|
|||||||
self._calibration_active = False
|
self._calibration_active = False
|
||||||
self._calibration_result = None
|
self._calibration_result = None
|
||||||
self._calibration_lock = threading.Lock()
|
self._calibration_lock = threading.Lock()
|
||||||
|
self._remote_detect_active = False
|
||||||
|
self._remote_detect_lock = threading.Lock()
|
||||||
|
self._remote_detect_result = None
|
||||||
self._laser_point = None
|
self._laser_point = None
|
||||||
self._laser_turned_on = False
|
self._laser_turned_on = False
|
||||||
self._last_frame_with_ellipse = None # 保存绘制了椭圆的图像(用于调试/显示)
|
self._last_frame_with_ellipse = None # 保存绘制了椭圆的图像(用于调试/显示)
|
||||||
|
self._remote_detect_last_pos = None
|
||||||
self._initialized = True
|
self._initialized = True
|
||||||
|
|
||||||
# ==================== 状态访问(只读属性)====================
|
# ==================== 状态访问(只读属性)====================
|
||||||
@@ -54,8 +59,8 @@ class LaserManager:
|
|||||||
@property
|
@property
|
||||||
def laser_point(self):
|
def laser_point(self):
|
||||||
"""当前激光点(如果启用硬编码,则返回硬编码值)"""
|
"""当前激光点(如果启用硬编码,则返回硬编码值)"""
|
||||||
# if config.HARDCODE_LASER_POINT:
|
if config.HARDCODE_LASER_POINT:
|
||||||
# return config.HARDCODE_LASER_POINT_VALUE
|
return config.HARDCODE_LASER_POINT_VALUE
|
||||||
return self._laser_point
|
return self._laser_point
|
||||||
|
|
||||||
def get_last_frame_with_ellipse(self):
|
def get_last_frame_with_ellipse(self):
|
||||||
@@ -102,30 +107,262 @@ class LaserManager:
|
|||||||
# ==================== 业务方法 ====================
|
# ==================== 业务方法 ====================
|
||||||
|
|
||||||
def load_laser_point(self):
|
def load_laser_point(self):
|
||||||
"""加载激光中心点:优先使用本地保存的坐标,其次硬编码值,最后默认值"""
|
"""从配置文件加载激光中心点,失败则使用默认值
|
||||||
# 优先:从本地持久化文件加载(由 cmd 201 保存)
|
如果启用硬编码模式,则直接使用硬编码值
|
||||||
|
"""
|
||||||
|
if config.HARDCODE_LASER_POINT:
|
||||||
|
# 硬编码模式:直接使用硬编码值
|
||||||
|
self._laser_point = config.HARDCODE_LASER_POINT_VALUE
|
||||||
|
self.logger.info(f"[LASER] 使用硬编码激光点: {self._laser_point}")
|
||||||
|
return self._laser_point
|
||||||
|
|
||||||
|
# 正常模式:从配置文件加载
|
||||||
try:
|
try:
|
||||||
if "laser_config.json" in os.listdir("/root"):
|
if os.path.exists(config.CONFIG_FILE):
|
||||||
with open(config.CONFIG_FILE, "r") as f:
|
with open(config.CONFIG_FILE, "r") as f:
|
||||||
data = json.load(f)
|
data = json.load(f)
|
||||||
if isinstance(data, list) and len(data) == 2:
|
if isinstance(data, list) and len(data) == 2:
|
||||||
self._laser_point = (int(data[0]), int(data[1]))
|
self._laser_point = (int(data[0]), int(data[1]))
|
||||||
self.logger.info(f"[LASER] 从本地加载激光点: {self._laser_point}")
|
self.logger.debug(f"[INFO] 加载激光点: {self._laser_point}")
|
||||||
return self._laser_point
|
return self._laser_point
|
||||||
except Exception:
|
else:
|
||||||
pass
|
raise ValueError
|
||||||
|
else:
|
||||||
# 其次:硬编码值
|
self._laser_point = config.DEFAULT_LASER_POINT
|
||||||
if config.HARDCODE_LASER_POINT:
|
except Exception as e:
|
||||||
self._laser_point = config.HARDCODE_LASER_POINT_VALUE
|
if self.logger:
|
||||||
self.logger.info(f"[LASER] 使用硬编码激光点: {self._laser_point}")
|
self.logger.warning(f"[LASER] 加载激光点失败,使用默认值: {e}")
|
||||||
return self._laser_point
|
self._laser_point = config.DEFAULT_LASER_POINT
|
||||||
|
|
||||||
# 最后:默认值
|
|
||||||
self._laser_point = config.DEFAULT_LASER_POINT
|
|
||||||
self.logger.info(f"[LASER] 使用默认激光点: {self._laser_point}")
|
|
||||||
return self._laser_point
|
return self._laser_point
|
||||||
|
|
||||||
|
@property
|
||||||
|
def remote_detect_active(self):
|
||||||
|
with self._remote_detect_lock:
|
||||||
|
return self._remote_detect_active
|
||||||
|
|
||||||
|
def get_remote_detect_result(self):
|
||||||
|
"""获取并清除远程激光识别结果 (x, y) 或 None。"""
|
||||||
|
with self._remote_detect_lock:
|
||||||
|
result = self._remote_detect_result
|
||||||
|
self._remote_detect_result = None
|
||||||
|
return result
|
||||||
|
|
||||||
|
def remote_detect_tick(self, frame):
|
||||||
|
"""
|
||||||
|
主循环显示路径调用的轻量 tick。
|
||||||
|
兼容旧调用点:当前远程识别由后台线程处理,这里不做重计算,
|
||||||
|
仅保留接口避免 AttributeError。
|
||||||
|
"""
|
||||||
|
return None
|
||||||
|
|
||||||
|
def overlay_remote_detect_preview(self, frame):
|
||||||
|
"""
|
||||||
|
在预览画面叠加远程识别点与坐标文本。
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
import cv2
|
||||||
|
from maix import image
|
||||||
|
with self._remote_detect_lock:
|
||||||
|
pos = self._remote_detect_last_pos
|
||||||
|
if not pos:
|
||||||
|
return frame
|
||||||
|
|
||||||
|
img_cv = image.image2cv(frame, False, False)
|
||||||
|
if img_cv is None or img_cv.size == 0:
|
||||||
|
return frame
|
||||||
|
|
||||||
|
x, y = int(pos[0]), int(pos[1])
|
||||||
|
h, w = img_cv.shape[:2]
|
||||||
|
if x < 0 or y < 0 or x >= w or y >= h:
|
||||||
|
return frame
|
||||||
|
|
||||||
|
color = (255, 0, 0) # RGB
|
||||||
|
cv2.circle(img_cv, (x, y), 8, color, 2)
|
||||||
|
cv2.line(img_cv, (x - 12, y), (x + 12, y), color, 1)
|
||||||
|
cv2.line(img_cv, (x, y - 12), (x, y + 12), color, 1)
|
||||||
|
cv2.putText(img_cv, f"laser=({x},{y})", (max(5, x + 10), max(20, y - 10)),
|
||||||
|
cv2.FONT_HERSHEY_SIMPLEX, 0.55, color, 1, cv2.LINE_AA)
|
||||||
|
|
||||||
|
return image.cv2image(img_cv, False, False)
|
||||||
|
except Exception as e:
|
||||||
|
if self.logger:
|
||||||
|
self.logger.debug(f"[LASER-REMOTE] overlay 绘制失败: {e}")
|
||||||
|
return frame
|
||||||
|
|
||||||
|
def _set_remote_detect_result(self, result):
|
||||||
|
with self._remote_detect_lock:
|
||||||
|
self._remote_detect_result = result
|
||||||
|
|
||||||
|
def set_hardcoded_laser_point(self, x, y):
|
||||||
|
"""更新 config.HARDCODE_LASER_POINT_VALUE(TCP cmd=201)。"""
|
||||||
|
try:
|
||||||
|
ix = int(round(float(x)))
|
||||||
|
iy = int(round(float(y)))
|
||||||
|
except (TypeError, ValueError) as e:
|
||||||
|
raise ValueError(f"invalid laser point ({x!r}, {y!r})") from e
|
||||||
|
config.HARDCODE_LASER_POINT = True
|
||||||
|
config.HARDCODE_LASER_POINT_VALUE = (ix, iy)
|
||||||
|
self._laser_point = (ix, iy)
|
||||||
|
try:
|
||||||
|
with open(config.CONFIG_FILE, "w") as f:
|
||||||
|
json.dump([ix, iy], f)
|
||||||
|
except Exception as e:
|
||||||
|
if self.logger:
|
||||||
|
self.logger.warning(f"[LASER] 保存硬编码激光点到本地失败: {e}")
|
||||||
|
raise
|
||||||
|
if self.logger:
|
||||||
|
self.logger.info(
|
||||||
|
f"[LASER] 已设置硬编码激光点 HARDCODE_LASER_POINT_VALUE=({ix}, {iy}) 并已保存到 {config.CONFIG_FILE}"
|
||||||
|
)
|
||||||
|
return ix, iy
|
||||||
|
|
||||||
|
def start_remote_laser_detect(self):
|
||||||
|
"""
|
||||||
|
启动远程激光识别会话(TCP cmd=200):开激光后持续检测。
|
||||||
|
每次稳定 3s 上报一次坐标,外循环直到 cmd=201 调用 stop_remote_laser_detect()。
|
||||||
|
Returns:
|
||||||
|
True 已启动;False 会话已在运行
|
||||||
|
"""
|
||||||
|
with self._remote_detect_lock:
|
||||||
|
if self._remote_detect_active:
|
||||||
|
return False
|
||||||
|
self._remote_detect_active = True
|
||||||
|
self._remote_detect_result = None
|
||||||
|
self._remote_detect_last_pos = None
|
||||||
|
_thread.start_new_thread(self._remote_laser_detect_worker, ())
|
||||||
|
if self.logger:
|
||||||
|
self.logger.info("[LASER] 远程激光识别已启动 (cmd=200)")
|
||||||
|
return True
|
||||||
|
|
||||||
|
def stop_remote_laser_detect(self):
|
||||||
|
with self._remote_detect_lock:
|
||||||
|
self._remote_detect_active = False
|
||||||
|
|
||||||
|
def _remote_laser_detect_worker(self):
|
||||||
|
from camera_manager import camera_manager
|
||||||
|
|
||||||
|
stable_sec = float(getattr(config, "LASER_REMOTE_DETECT_STABLE_SEC", 3.0))
|
||||||
|
max_move = float(getattr(config, "LASER_REMOTE_DETECT_MAX_MOVE_PX", 12.0))
|
||||||
|
sample_ms = int(getattr(config, "LASER_REMOTE_DETECT_SAMPLE_MS", 80))
|
||||||
|
min_samples = int(getattr(config, "LASER_REMOTE_DETECT_MIN_SAMPLES", 8))
|
||||||
|
warmup_ms = int(getattr(config, "LASER_REMOTE_DETECT_WARMUP_MS", 500))
|
||||||
|
stable_ms = int(max(500, stable_sec * 1000))
|
||||||
|
|
||||||
|
samples = []
|
||||||
|
miss_count = 0
|
||||||
|
stable_hit_count = 0
|
||||||
|
reported = False
|
||||||
|
|
||||||
|
try:
|
||||||
|
if not self._laser_turned_on:
|
||||||
|
try:
|
||||||
|
self.turn_on_laser()
|
||||||
|
except Exception as e:
|
||||||
|
if self.logger:
|
||||||
|
self.logger.warning(f"[LASER] cmd200 worker 开激光失败: {e}")
|
||||||
|
if warmup_ms > 0:
|
||||||
|
if self.logger:
|
||||||
|
self.logger.info(f"[LASER] cmd200 激光预热 {warmup_ms}ms …")
|
||||||
|
time.sleep_ms(warmup_ms)
|
||||||
|
|
||||||
|
if self.logger:
|
||||||
|
self.logger.info("[LASER] 远程识别外循环已启动,直至 cmd=201 停止")
|
||||||
|
|
||||||
|
while True:
|
||||||
|
with self._remote_detect_lock:
|
||||||
|
if not self._remote_detect_active:
|
||||||
|
if self.logger:
|
||||||
|
self.logger.info("[LASER] 远程识别会话结束 (cmd=201 或取消)")
|
||||||
|
return
|
||||||
|
|
||||||
|
try:
|
||||||
|
frame = camera_manager.read_frame()
|
||||||
|
pos = self.find_red_laser_remote(frame)
|
||||||
|
except Exception as e:
|
||||||
|
if self.logger:
|
||||||
|
self.logger.warning(f"[LASER] 远程识别帧异常: {e}")
|
||||||
|
pos = None
|
||||||
|
time.sleep_ms(sample_ms)
|
||||||
|
continue
|
||||||
|
|
||||||
|
now_ms = time.ticks_ms()
|
||||||
|
if pos is None:
|
||||||
|
miss_count += 1
|
||||||
|
samples.clear()
|
||||||
|
stable_hit_count = 0
|
||||||
|
if miss_count == 1 or miss_count % 40 == 0:
|
||||||
|
if self.logger:
|
||||||
|
self.logger.info(
|
||||||
|
f"[LASER-REMOTE] 本帧未检出激光点(累计 {miss_count} 帧),"
|
||||||
|
f"全图多策略搜索中…"
|
||||||
|
)
|
||||||
|
time.sleep_ms(sample_ms)
|
||||||
|
continue
|
||||||
|
|
||||||
|
miss_count = 0
|
||||||
|
x, y = float(pos[0]), float(pos[1])
|
||||||
|
samples.append((now_ms, x, y))
|
||||||
|
cutoff = now_ms - stable_ms
|
||||||
|
samples = [(t, px, py) for t, px, py in samples if t >= cutoff]
|
||||||
|
|
||||||
|
if len(samples) < 2:
|
||||||
|
time.sleep_ms(sample_ms)
|
||||||
|
continue
|
||||||
|
|
||||||
|
xs = [s[1] for s in samples]
|
||||||
|
ys = [s[2] for s in samples]
|
||||||
|
span = max(
|
||||||
|
max(xs) - min(xs),
|
||||||
|
max(ys) - min(ys),
|
||||||
|
)
|
||||||
|
for i in range(len(samples)):
|
||||||
|
for j in range(i + 1, len(samples)):
|
||||||
|
d = math.hypot(
|
||||||
|
samples[i][1] - samples[j][1],
|
||||||
|
samples[i][2] - samples[j][2],
|
||||||
|
)
|
||||||
|
span = max(span, d)
|
||||||
|
|
||||||
|
if span > max_move:
|
||||||
|
if self.logger:
|
||||||
|
self.logger.debug(
|
||||||
|
f"[LASER] 检测到大幅位移 span={span:.1f}px>{max_move},重新计时"
|
||||||
|
)
|
||||||
|
samples.clear()
|
||||||
|
stable_hit_count = 0
|
||||||
|
time.sleep_ms(sample_ms)
|
||||||
|
continue
|
||||||
|
|
||||||
|
window_ms = samples[-1][0] - samples[0][0]
|
||||||
|
if window_ms >= stable_ms and len(samples) >= min_samples:
|
||||||
|
fx = int(round(sum(xs) / len(xs)))
|
||||||
|
fy = int(round(sum(ys) / len(ys)))
|
||||||
|
stable_hit_count += 1
|
||||||
|
if self.logger:
|
||||||
|
self.logger.info(
|
||||||
|
f"[LASER] 远程识别稳定命中 {stable_hit_count}/3 span={span:.1f}px → ({fx}, {fy})"
|
||||||
|
)
|
||||||
|
samples.clear()
|
||||||
|
if stable_hit_count >= 3 and not reported:
|
||||||
|
reported = True
|
||||||
|
self._set_remote_detect_result(
|
||||||
|
{"result":"laser_detect_ok", "x": fx, "y": fy}
|
||||||
|
)
|
||||||
|
if self.logger:
|
||||||
|
self.logger.info(
|
||||||
|
f"[LASER] 已连续3次坐标稳定,完成上报,继续等待 cmd=201 关闭会话"
|
||||||
|
)
|
||||||
|
time.sleep_ms(sample_ms)
|
||||||
|
continue
|
||||||
|
|
||||||
|
time.sleep_ms(sample_ms)
|
||||||
|
finally:
|
||||||
|
if self.logger:
|
||||||
|
self.logger.info("[LASER] 远程识别线程退出,等待下一次 cmd=200")
|
||||||
|
with self._remote_detect_lock:
|
||||||
|
self._remote_detect_active = False
|
||||||
|
|
||||||
def save_laser_point(self, point):
|
def save_laser_point(self, point):
|
||||||
"""保存激光中心点到配置文件
|
"""保存激光中心点到配置文件
|
||||||
如果启用硬编码模式,则不保存(直接返回 True)
|
如果启用硬编码模式,则不保存(直接返回 True)
|
||||||
@@ -828,6 +1065,172 @@ class LaserManager:
|
|||||||
# 使用原来的最亮点方法
|
# 使用原来的最亮点方法
|
||||||
return self._find_red_laser_brightest(frame, threshold, search_radius, ellipse_params)
|
return self._find_red_laser_brightest(frame, threshold, search_radius, ellipse_params)
|
||||||
|
|
||||||
|
def find_red_laser_remote(self, frame):
|
||||||
|
"""
|
||||||
|
cmd=200 远程识别专用:全图搜索、多策略、放宽阈值,不限距画面中心距离。
|
||||||
|
常规 find_red_laser 仅搜中心 ±LASER_SEARCH_RADIUS 且距中心 >50px 会丢弃。
|
||||||
|
"""
|
||||||
|
import cv2
|
||||||
|
import numpy as np
|
||||||
|
from maix import image
|
||||||
|
|
||||||
|
img_cv = image.image2cv(frame, False, False)
|
||||||
|
if img_cv is None or img_cv.size == 0:
|
||||||
|
return None
|
||||||
|
h, w = img_cv.shape[:2]
|
||||||
|
|
||||||
|
r = img_cv[:, :, 0].astype(np.int32)
|
||||||
|
g = img_cv[:, :, 1].astype(np.int32)
|
||||||
|
b = img_cv[:, :, 2].astype(np.int32)
|
||||||
|
brightness = r + g + b
|
||||||
|
red_ratio = float(getattr(config, "LASER_RED_RATIO", 1.5))
|
||||||
|
ratio_lo = max(1.15, red_ratio - 0.35)
|
||||||
|
|
||||||
|
strategies = []
|
||||||
|
base_th = int(getattr(config, "LASER_DETECTION_THRESHOLD", 140))
|
||||||
|
for th in (base_th, 120, 100, 80, 60):
|
||||||
|
mask = (
|
||||||
|
(r > th)
|
||||||
|
& (r > g * ratio_lo)
|
||||||
|
& (r > b * ratio_lo)
|
||||||
|
)
|
||||||
|
strategies.append(("rgb", th, mask))
|
||||||
|
|
||||||
|
oe_th = int(getattr(config, "LASER_OVEREXPOSED_THRESHOLD", 200))
|
||||||
|
oe_diff = int(getattr(config, "LASER_OVEREXPOSED_DIFF", 10))
|
||||||
|
mask_oe = (
|
||||||
|
(r > oe_th - 30)
|
||||||
|
& (g > oe_th - 40)
|
||||||
|
& (b > oe_th - 40)
|
||||||
|
& (r >= g)
|
||||||
|
& (r >= b)
|
||||||
|
& ((r - g) > max(5, oe_diff - 5))
|
||||||
|
& ((r - b) > max(5, oe_diff - 5))
|
||||||
|
)
|
||||||
|
strategies.append(("overexposed", oe_th, mask_oe))
|
||||||
|
|
||||||
|
mask_bright = (brightness > 380) & (r >= g) & (r >= b) & ((r - g) > 3)
|
||||||
|
strategies.append(("bright", 0, mask_bright))
|
||||||
|
|
||||||
|
hsv = cv2.cvtColor(img_cv, cv2.COLOR_RGB2HSV)
|
||||||
|
hc, sc, vc = cv2.split(hsv)
|
||||||
|
mask_hsv = ((hc <= 18) | (hc >= 162)) & (sc >= 60) & (vc >= 60)
|
||||||
|
strategies.append(("hsv", 0, mask_hsv))
|
||||||
|
|
||||||
|
best_pos = None
|
||||||
|
best_score = -1.0
|
||||||
|
best_tag = None
|
||||||
|
|
||||||
|
max_area = float(getattr(config, "LASER_REMOTE_MAX_AREA", 300.0))
|
||||||
|
min_circularity = float(getattr(config, "LASER_REMOTE_MIN_CIRCULARITY", 0.25))
|
||||||
|
|
||||||
|
for name, th, mask in strategies:
|
||||||
|
m = (mask.astype(np.uint8)) * 255
|
||||||
|
if cv2.countNonZero(m) == 0:
|
||||||
|
continue
|
||||||
|
contours, _ = cv2.findContours(m, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE)
|
||||||
|
if not contours:
|
||||||
|
continue
|
||||||
|
for cnt in contours:
|
||||||
|
area = cv2.contourArea(cnt)
|
||||||
|
if area < 1.5 or area > max_area:
|
||||||
|
continue
|
||||||
|
peri = cv2.arcLength(cnt, True)
|
||||||
|
if peri <= 0:
|
||||||
|
continue
|
||||||
|
circularity = float(4.0 * math.pi * area / (peri * peri))
|
||||||
|
if circularity < min_circularity:
|
||||||
|
continue
|
||||||
|
M = cv2.moments(cnt)
|
||||||
|
if M["m00"] <= 0:
|
||||||
|
continue
|
||||||
|
cx = float(M["m10"] / M["m00"])
|
||||||
|
cy = float(M["m01"] / M["m00"])
|
||||||
|
ix, iy = int(round(cx)), int(round(cy))
|
||||||
|
if ix < 0 or iy < 0 or ix >= w or iy >= h:
|
||||||
|
continue
|
||||||
|
local_r = float(r[iy, ix])
|
||||||
|
score = area * local_r * (1.0 + local_r / 255.0) * (0.5 + circularity)
|
||||||
|
if score > best_score:
|
||||||
|
best_score = score
|
||||||
|
best_pos = (ix, iy)
|
||||||
|
best_tag = (name, th, area)
|
||||||
|
|
||||||
|
if best_pos is not None:
|
||||||
|
with self._remote_detect_lock:
|
||||||
|
self._remote_detect_last_pos = best_pos
|
||||||
|
self._save_remote_detect_debug_image(frame, best_pos, best_tag)
|
||||||
|
if self.logger:
|
||||||
|
self.logger.info(
|
||||||
|
f"[LASER-REMOTE] 检测到激光点 {best_pos} "
|
||||||
|
f"strategy={best_tag[0]} th={best_tag[1]} area={best_tag[2]:.1f}"
|
||||||
|
)
|
||||||
|
elif self.logger:
|
||||||
|
self.logger.debug("[LASER-REMOTE] 未通过面积/圆度过滤")
|
||||||
|
return best_pos
|
||||||
|
|
||||||
|
def _save_remote_detect_debug_image(self, frame, pos, tag=None):
|
||||||
|
"""保存远程识别调试图:叠加激光坐标并落盘。"""
|
||||||
|
try:
|
||||||
|
if not bool(getattr(config, "SAVE_IMAGE_ENABLED", True)):
|
||||||
|
return
|
||||||
|
import cv2
|
||||||
|
from maix import image
|
||||||
|
|
||||||
|
img_cv = image.image2cv(frame, False, False)
|
||||||
|
if img_cv is None or img_cv.size == 0:
|
||||||
|
return
|
||||||
|
|
||||||
|
x, y = int(pos[0]), int(pos[1])
|
||||||
|
h, w = img_cv.shape[:2]
|
||||||
|
if x < 0 or y < 0 or x >= w or y >= h:
|
||||||
|
return
|
||||||
|
|
||||||
|
cv2.circle(img_cv, (x, y), 8, (255, 0, 0), 2)
|
||||||
|
cv2.line(img_cv, (x - 12, y), (x + 12, y), (255, 0, 0), 1)
|
||||||
|
cv2.line(img_cv, (x, y - 12), (x, y + 12), (255, 0, 0), 1)
|
||||||
|
|
||||||
|
desc = ""
|
||||||
|
if tag:
|
||||||
|
desc = f" {tag[0]} th={tag[1]} area={tag[2]:.1f}"
|
||||||
|
cv2.putText(
|
||||||
|
img_cv,
|
||||||
|
f"laser=({x},{y}){desc}",
|
||||||
|
(10, 24),
|
||||||
|
cv2.FONT_HERSHEY_SIMPLEX,
|
||||||
|
0.55,
|
||||||
|
(255, 0, 0),
|
||||||
|
1,
|
||||||
|
cv2.LINE_AA,
|
||||||
|
)
|
||||||
|
|
||||||
|
base_dir = getattr(config, "PHOTO_DIR", "/root/phot")
|
||||||
|
debug_dir = f"{base_dir}/laser_remote"
|
||||||
|
try:
|
||||||
|
if debug_dir not in os.listdir("/root") and "/" not in debug_dir.replace("/root/", ""):
|
||||||
|
os.mkdir(debug_dir)
|
||||||
|
else:
|
||||||
|
try:
|
||||||
|
os.makedirs(debug_dir, exist_ok=True)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
except Exception:
|
||||||
|
try:
|
||||||
|
os.makedirs(debug_dir, exist_ok=True)
|
||||||
|
except Exception:
|
||||||
|
return
|
||||||
|
|
||||||
|
ts = int(time.ticks_ms())
|
||||||
|
filename = f"{debug_dir}/remote_{x}_{y}_{ts}.jpg"
|
||||||
|
out = image.cv2image(img_cv, False, False)
|
||||||
|
out.save(filename)
|
||||||
|
|
||||||
|
if self.logger:
|
||||||
|
self.logger.info(f"[LASER-REMOTE] 调试图已保存: {filename}")
|
||||||
|
except Exception as e:
|
||||||
|
if self.logger:
|
||||||
|
self.logger.warning(f"[LASER-REMOTE] 保存调试图失败: {e}")
|
||||||
|
|
||||||
def calibrate_laser_position(self, timeout_ms=8000, check_sharpness=True):
|
def calibrate_laser_position(self, timeout_ms=8000, check_sharpness=True):
|
||||||
"""
|
"""
|
||||||
执行激光校准:循环拍照 → 检测靶心 → 检查激光点清晰度 → 找红点 → 保存坐标
|
执行激光校准:循环拍照 → 检测靶心 → 检查激光点清晰度 → 找红点 → 保存坐标
|
||||||
@@ -1261,28 +1664,6 @@ class LaserManager:
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
self.logger.error(f"[LASER] 关闭激光失败: {e}")
|
self.logger.error(f"[LASER] 关闭激光失败: {e}")
|
||||||
|
|
||||||
def set_hardcoded_laser_point(self, raw_x, raw_y):
|
|
||||||
"""
|
|
||||||
设置服务下发的硬编码激光点坐标,并保存到本地持久化文件。
|
|
||||||
下次启动时 load_laser_point() 会优先使用此保存的值。
|
|
||||||
|
|
||||||
Args:
|
|
||||||
raw_x: 服务下发的 x 坐标
|
|
||||||
raw_y: 服务下发的 y 坐标
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
(int_x, int_y) 元组
|
|
||||||
"""
|
|
||||||
ix = int(raw_x)
|
|
||||||
iy = int(raw_y)
|
|
||||||
self._laser_point = (ix, iy)
|
|
||||||
try:
|
|
||||||
with open(config.CONFIG_FILE, "w") as f:
|
|
||||||
json.dump([ix, iy], f)
|
|
||||||
self.logger.info(f"[LASER] 设置并持久化激光点: ({ix}, {iy})")
|
|
||||||
except Exception as e:
|
|
||||||
self.logger.error(f"[LASER] 持久化激光点失败: {e}")
|
|
||||||
return ix, iy
|
|
||||||
|
|
||||||
# 创建全局单例实例
|
# 创建全局单例实例
|
||||||
laser_manager = LaserManager()
|
laser_manager = LaserManager()
|
||||||
|
|||||||
@@ -65,8 +65,8 @@ class LoggerManager:
|
|||||||
backup_count = config.LOG_BACKUP_COUNT
|
backup_count = config.LOG_BACKUP_COUNT
|
||||||
|
|
||||||
try:
|
try:
|
||||||
# 创建日志队列(有界队列,防止内存泄漏;满时自动丢弃旧日志)
|
# 创建日志队列(无界队列)
|
||||||
self._log_queue = queue.Queue(maxsize=config.LOG_QUEUE_MAXSIZE)
|
self._log_queue = queue.Queue(-1)
|
||||||
|
|
||||||
# 确保日志文件所在的目录存在
|
# 确保日志文件所在的目录存在
|
||||||
log_dir = os.path.dirname(log_file)
|
log_dir = os.path.dirname(log_file)
|
||||||
|
|||||||
46
main.py
46
main.py
@@ -290,33 +290,34 @@ def cmd_str():
|
|||||||
last_avg_abs = 0
|
last_avg_abs = 0
|
||||||
|
|
||||||
def _flush_pressure_buf(reason: str):
|
def _flush_pressure_buf(reason: str):
|
||||||
|
if not config.AIR_PRESSURE_lOG:
|
||||||
|
return
|
||||||
nonlocal pressure_buf, pressure_sum, pressure_min, pressure_max, pressure_t0_ms, logger, pressure_abs_sum, last_avg_abs
|
nonlocal pressure_buf, pressure_sum, pressure_min, pressure_max, pressure_t0_ms, logger, pressure_abs_sum, last_avg_abs
|
||||||
if not pressure_buf:
|
if not pressure_buf:
|
||||||
return
|
return
|
||||||
if config.AIR_PRESSURE_lOG:
|
t1_ms = time.ticks_ms()
|
||||||
t1_ms = time.ticks_ms()
|
n = len(pressure_buf)
|
||||||
n = len(pressure_buf)
|
avg = (pressure_sum / n) if n else 0
|
||||||
avg = (pressure_sum / n) if n else 0
|
avg_abs = (pressure_abs_sum / n) if n else 0
|
||||||
avg_abs = (pressure_abs_sum / n) if n else 0
|
# 一行输出:方便后处理画曲线;同时带上统计信息便于快速看波峰
|
||||||
line = (
|
line = (
|
||||||
f"[气压批量] reason={reason} "
|
f"[气压批量] reason={reason} "
|
||||||
f"t0={pressure_t0_ms} t1={t1_ms} n={n} "
|
f"t0={pressure_t0_ms} t1={t1_ms} n={n} "
|
||||||
f"min={pressure_min} max={pressure_max} avg={avg:.1f} avg_abs={avg_abs:.3f} "
|
f"min={pressure_min} max={pressure_max} avg={avg:.1f} avg_abs={avg_abs:.3f} "
|
||||||
f"values={','.join(map(str, pressure_buf))}"
|
f"values={','.join(map(str, pressure_buf))}"
|
||||||
f" convert value (kpa): {(max(pressure_buf, key=lambda x: x[1])[1] - last_avg_abs) / (5 - 2.5) * config.AIR_PRESSURE_HARDWARE_MAX:.1f}"
|
f" convert value (kpa): {(max(pressure_buf, key=lambda x: x[1])[1] - last_avg_abs) / (5 - 2.5) * config.AIR_PRESSURE_HARDWARE_MAX:.1f}"
|
||||||
)
|
)
|
||||||
if logger:
|
if logger:
|
||||||
logger.debug(line)
|
logger.debug(line)
|
||||||
else:
|
else:
|
||||||
print(line)
|
print(line)
|
||||||
last_avg_abs = avg_abs
|
|
||||||
# 无论是否记录日志,都必须清空 buffer,否则内存泄漏
|
|
||||||
pressure_buf = []
|
pressure_buf = []
|
||||||
pressure_sum = 0
|
pressure_sum = 0
|
||||||
pressure_abs_sum = 0
|
pressure_abs_sum = 0
|
||||||
pressure_min = 4095
|
pressure_min = 4095
|
||||||
pressure_max = 0
|
pressure_max = 0
|
||||||
pressure_t0_ms = None
|
pressure_t0_ms = None
|
||||||
|
last_avg_abs = avg_abs
|
||||||
|
|
||||||
# 主循环:检测扳机触发 → 拍照 → 分析 → 上报
|
# 主循环:检测扳机触发 → 拍照 → 分析 → 上报
|
||||||
while not app.need_exit():
|
while not app.need_exit():
|
||||||
@@ -401,7 +402,14 @@ def cmd_str():
|
|||||||
else:
|
else:
|
||||||
if config.SHOW_CAMERA_PHOTO_WHILE_SHOOTING:
|
if config.SHOW_CAMERA_PHOTO_WHILE_SHOOTING:
|
||||||
try:
|
try:
|
||||||
camera_manager.show(camera_manager.read_frame())
|
frame = camera_manager.read_frame()
|
||||||
|
laser_manager.remote_detect_tick(frame)
|
||||||
|
if (
|
||||||
|
laser_manager.remote_detect_active
|
||||||
|
and getattr(config, "LASER_REMOTE_DETECT_DRAW_PREVIEW", False)
|
||||||
|
):
|
||||||
|
frame = laser_manager.overlay_remote_detect_preview(frame)
|
||||||
|
camera_manager.show(frame)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger = logger_manager.logger
|
logger = logger_manager.logger
|
||||||
if logger:
|
if logger:
|
||||||
|
|||||||
Binary file not shown.
@@ -1,13 +0,0 @@
|
|||||||
|
|
||||||
[basic]
|
|
||||||
type = cvimodel
|
|
||||||
model = model_270820.cvimodel
|
|
||||||
|
|
||||||
[extra]
|
|
||||||
model_type = yolov5
|
|
||||||
input_type = rgb
|
|
||||||
mean = 0, 0, 0
|
|
||||||
scale = 0.00392156862745098, 0.00392156862745098, 0.00392156862745098
|
|
||||||
anchors = 10, 13, 16, 30, 33, 23, 30, 61, 62, 45, 59, 119, 116, 90, 156, 198, 373, 326
|
|
||||||
labels = triangle
|
|
||||||
|
|
||||||
709
network.py
709
network.py
File diff suppressed because it is too large
Load Diff
57
ota_curl.sh
57
ota_curl.sh
@@ -1,57 +0,0 @@
|
|||||||
#!/bin/sh
|
|
||||||
# OTA 更新脚本 - 使用 curl 断点下载
|
|
||||||
# 用法: sh ota_curl.sh <下载URL>
|
|
||||||
# 示例: sh ota_curl.sh http://example.com/maix-t11-v2.15.1.zip
|
|
||||||
|
|
||||||
set -e
|
|
||||||
|
|
||||||
APP_DIR="/maixapp/apps/t11"
|
|
||||||
BACKUP_BASE="$APP_DIR/backups"
|
|
||||||
TMP_DIR="/tmp/ota_curl"
|
|
||||||
PENDING_FILE="$APP_DIR/ota_pending.json"
|
|
||||||
|
|
||||||
if [ $# -lt 1 ]; then
|
|
||||||
echo "用法: $0 <下载URL>"
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
OTA_URL="$1"
|
|
||||||
FILENAME=$(basename "$OTA_URL" | sed 's/?.*//')
|
|
||||||
[ -z "$FILENAME" ] && FILENAME="update.zip"
|
|
||||||
|
|
||||||
mkdir -p "$TMP_DIR" "$BACKUP_BASE"
|
|
||||||
|
|
||||||
# 1. 断点下载
|
|
||||||
echo "[OTA] 开始下载: $OTA_URL"
|
|
||||||
echo "[OTA] 保存到: $TMP_DIR/$FILENAME"
|
|
||||||
curl -C - -L --retry 3 --retry-delay 5 -o "$TMP_DIR/$FILENAME" "$OTA_URL"
|
|
||||||
echo "[OTA] 下载完成"
|
|
||||||
|
|
||||||
# 2. 备份当前目录
|
|
||||||
TIMESTAMP=$(date +%Y%m%d_%H%M%S 2>/dev/null || echo "00000000_000000")
|
|
||||||
BACKUP_DIR="$BACKUP_BASE/backup_$TIMESTAMP"
|
|
||||||
mkdir -p "$BACKUP_DIR"
|
|
||||||
echo "[OTA] 备份到: $BACKUP_DIR"
|
|
||||||
for f in "$APP_DIR"/*.py "$APP_DIR"/*.json "$APP_DIR"/*.xml "$APP_DIR"/*.yaml "$APP_DIR"/*.pem "$APP_DIR"/*.mud "$APP_DIR"/*.so "$APP_DIR"/S99archery; do
|
|
||||||
[ -f "$f" ] && cp "$f" "$BACKUP_DIR/"
|
|
||||||
done
|
|
||||||
echo "[OTA] 备份完成"
|
|
||||||
|
|
||||||
# 3. 解压并替换文件
|
|
||||||
echo "[OTA] 开始更新..."
|
|
||||||
if echo "$FILENAME" | grep -qi '\.zip$'; then
|
|
||||||
unzip -q -o "$TMP_DIR/$FILENAME" -d "$APP_DIR/"
|
|
||||||
else
|
|
||||||
cp "$TMP_DIR/$FILENAME" "$APP_DIR/"
|
|
||||||
fi
|
|
||||||
sync
|
|
||||||
|
|
||||||
# 4. 写入 pending 文件(用于崩溃恢复)
|
|
||||||
echo '{"ts":0,"url":"'"$OTA_URL"'","backup_dir":"'"$BACKUP_DIR"'","restart_count":0,"max_restarts":3}' > "$PENDING_FILE"
|
|
||||||
sync
|
|
||||||
|
|
||||||
echo "[OTA] 更新完成,准备重启..."
|
|
||||||
|
|
||||||
# 5. 重启
|
|
||||||
sleep 1
|
|
||||||
reboot
|
|
||||||
169
shoot_manager.py
169
shoot_manager.py
@@ -8,7 +8,7 @@ from laser_manager import laser_manager
|
|||||||
from logger_manager import logger_manager
|
from logger_manager import logger_manager
|
||||||
from network import network_manager
|
from network import network_manager
|
||||||
from triangle_target import load_camera_from_xml, load_triangle_positions, try_triangle_scoring
|
from triangle_target import load_camera_from_xml, load_triangle_positions, try_triangle_scoring
|
||||||
from vision import estimate_distance, detect_circle_v3, enqueue_save_shot
|
from vision import estimate_distance, detect_circle_v3, enqueue_save_shot, enqueue_save_raw_shot
|
||||||
from maix import image, time
|
from maix import image, time
|
||||||
|
|
||||||
# 缓存相机标定与三角形位置,避免每次射箭重复读磁盘
|
# 缓存相机标定与三角形位置,避免每次射箭重复读磁盘
|
||||||
@@ -58,6 +58,7 @@ def analyze_shot(frame, laser_point=None):
|
|||||||
# ── Step 1: 确定激光点 ────────────────────────────────────────────────────
|
# ── Step 1: 确定激光点 ────────────────────────────────────────────────────
|
||||||
laser_point_method = None
|
laser_point_method = None
|
||||||
distance_m_first = None
|
distance_m_first = None
|
||||||
|
best_radius1_temp = None
|
||||||
|
|
||||||
if config.HARDCODE_LASER_POINT:
|
if config.HARDCODE_LASER_POINT:
|
||||||
laser_point = laser_manager.laser_point
|
laser_point = laser_manager.laser_point
|
||||||
@@ -102,9 +103,22 @@ def analyze_shot(frame, laser_point=None):
|
|||||||
r_img, center, radius, method, best_radius1, ellipse_params = cdata
|
r_img, center, radius, method, best_radius1, ellipse_params = cdata
|
||||||
dx, dy = None, None
|
dx, dy = None, None
|
||||||
d_m = distance_m_first
|
d_m = distance_m_first
|
||||||
|
tri_h = None
|
||||||
if center and radius:
|
if center and radius:
|
||||||
dx, dy = laser_manager.compute_laser_position(center, (x, y), radius, method)
|
dx, dy = laser_manager.compute_laser_position(center, (x, y), radius, method)
|
||||||
d_m = estimate_distance(best_radius1) if best_radius1 else distance_m_first
|
d_m = estimate_distance(best_radius1) if best_radius1 else distance_m_first
|
||||||
|
try:
|
||||||
|
import numpy as _np
|
||||||
|
px_per_cm = float(radius) / 10.0
|
||||||
|
if px_per_cm > 1e-6:
|
||||||
|
cxp, cyp = float(center[0]), float(center[1])
|
||||||
|
tri_h = _np.array([
|
||||||
|
[1.0 / px_per_cm, 0.0, -cxp / px_per_cm],
|
||||||
|
[0.0, 1.0 / px_per_cm, -cyp / px_per_cm],
|
||||||
|
[0.0, 0.0, 1.0],
|
||||||
|
], dtype=float)
|
||||||
|
except Exception:
|
||||||
|
tri_h = None
|
||||||
out = {
|
out = {
|
||||||
"success": True,
|
"success": True,
|
||||||
"result_img": r_img,
|
"result_img": r_img,
|
||||||
@@ -114,6 +128,7 @@ def analyze_shot(frame, laser_point=None):
|
|||||||
"laser_point": laser_point, "laser_point_method": laser_point_method,
|
"laser_point": laser_point, "laser_point_method": laser_point_method,
|
||||||
"offset_method": "yellow_ellipse" if ellipse_params else "yellow_circle",
|
"offset_method": "yellow_ellipse" if ellipse_params else "yellow_circle",
|
||||||
"distance_method": "yellow_radius",
|
"distance_method": "yellow_radius",
|
||||||
|
"tri_homography": tri_h,
|
||||||
}
|
}
|
||||||
if yolo_roi_xyxy is not None:
|
if yolo_roi_xyxy is not None:
|
||||||
out["yolo_roi_xyxy"] = yolo_roi_xyxy
|
out["yolo_roi_xyxy"] = yolo_roi_xyxy
|
||||||
@@ -129,8 +144,10 @@ def analyze_shot(frame, laser_point=None):
|
|||||||
roi_xyxy = None
|
roi_xyxy = None
|
||||||
yolo_ring_ms = 0.0
|
yolo_ring_ms = 0.0
|
||||||
yolo_black_ms = 0.0
|
yolo_black_ms = 0.0
|
||||||
|
_timing_on = bool(getattr(config, "ARCHERY_TIMING_ENABLE", True))
|
||||||
|
_sample_on = bool(getattr(config, "TRIANGLE_SAMPLE_ENABLE", False))
|
||||||
if getattr(config, "TRIANGLE_YOLO_ROI_ENABLE", False):
|
if getattr(config, "TRIANGLE_YOLO_ROI_ENABLE", False):
|
||||||
_t_yolo_ring = time_std.perf_counter()
|
_t_yolo_ring = time_std.perf_counter() if _timing_on else None
|
||||||
try:
|
try:
|
||||||
from target_roi_yolo import try_get_triangle_roi_from_yolo
|
from target_roi_yolo import try_get_triangle_roi_from_yolo
|
||||||
roi_xyxy = try_get_triangle_roi_from_yolo(
|
roi_xyxy = try_get_triangle_roi_from_yolo(
|
||||||
@@ -140,7 +157,8 @@ def analyze_shot(frame, laser_point=None):
|
|||||||
if logger:
|
if logger:
|
||||||
logger.warning(f"[YOLO-ROI] {e}")
|
logger.warning(f"[YOLO-ROI] {e}")
|
||||||
finally:
|
finally:
|
||||||
yolo_ring_ms = (time_std.perf_counter() - _t_yolo_ring) * 1000.0
|
if _timing_on and _t_yolo_ring is not None:
|
||||||
|
yolo_ring_ms = (time_std.perf_counter() - _t_yolo_ring) * 1000.0
|
||||||
|
|
||||||
_loc_mode = str(
|
_loc_mode = str(
|
||||||
getattr(config, "TRIANGLE_BLACK_TRIANGLE_LOCATE_MODE", "yolo")
|
getattr(config, "TRIANGLE_BLACK_TRIANGLE_LOCATE_MODE", "yolo")
|
||||||
@@ -155,7 +173,7 @@ def analyze_shot(frame, laser_point=None):
|
|||||||
and roi_xyxy is not None
|
and roi_xyxy is not None
|
||||||
)
|
)
|
||||||
if _run_stage2_black_yolo:
|
if _run_stage2_black_yolo:
|
||||||
_t_yolo_black = time_std.perf_counter()
|
_t_yolo_black = time_std.perf_counter() if _timing_on else None
|
||||||
try:
|
try:
|
||||||
from target_roi_yolo import try_black_triangle_boxes_work
|
from target_roi_yolo import try_black_triangle_boxes_work
|
||||||
|
|
||||||
@@ -166,7 +184,8 @@ def analyze_shot(frame, laser_point=None):
|
|||||||
if logger:
|
if logger:
|
||||||
logger.warning(f"[YOLO-BLACK] {e}")
|
logger.warning(f"[YOLO-BLACK] {e}")
|
||||||
finally:
|
finally:
|
||||||
yolo_black_ms = (time_std.perf_counter() - _t_yolo_black) * 1000.0
|
if _timing_on and _t_yolo_black is not None:
|
||||||
|
yolo_black_ms = (time_std.perf_counter() - _t_yolo_black) * 1000.0
|
||||||
elif (
|
elif (
|
||||||
logger
|
logger
|
||||||
and _loc_mode == "traditional"
|
and _loc_mode == "traditional"
|
||||||
@@ -184,7 +203,7 @@ def analyze_shot(frame, laser_point=None):
|
|||||||
try:
|
try:
|
||||||
logger.info(f"[TRI] begin {datetime.now()}")
|
logger.info(f"[TRI] begin {datetime.now()}")
|
||||||
logger.info(f"[TRI] K: {K}, dist: {dist_coef}, pos: {pos}, {datetime.now()}")
|
logger.info(f"[TRI] K: {K}, dist: {dist_coef}, pos: {pos}, {datetime.now()}")
|
||||||
_t_wall_try = time_std.perf_counter()
|
_t_wall_try = time_std.perf_counter() if _timing_on else None
|
||||||
tri = try_triangle_scoring(
|
tri = try_triangle_scoring(
|
||||||
img_cv, (x, y), pos, K, dist_coef,
|
img_cv, (x, y), pos, K, dist_coef,
|
||||||
size_range=getattr(config, "TRIANGLE_SIZE_RANGE", (8, 500)),
|
size_range=getattr(config, "TRIANGLE_SIZE_RANGE", (8, 500)),
|
||||||
@@ -193,8 +212,8 @@ def analyze_shot(frame, laser_point=None):
|
|||||||
yolo_ring_ms=yolo_ring_ms,
|
yolo_ring_ms=yolo_ring_ms,
|
||||||
yolo_black_ms=yolo_black_ms,
|
yolo_black_ms=yolo_black_ms,
|
||||||
)
|
)
|
||||||
_wall_try_ms = (time_std.perf_counter() - _t_wall_try) * 1000.0
|
_wall_try_ms = (time_std.perf_counter() - _t_wall_try) * 1000.0 if _timing_on else 0.0
|
||||||
if logger and bool(getattr(config, "TRIANGLE_LOG_E2E_TIMING", True)):
|
if logger and bool(getattr(config, "TRIANGLE_LOG_E2E_TIMING", True)) and _timing_on:
|
||||||
_e2e = float(yolo_ring_ms) + float(yolo_black_ms) + float(_wall_try_ms)
|
_e2e = float(yolo_ring_ms) + float(yolo_black_ms) + float(_wall_try_ms)
|
||||||
logger.info(
|
logger.info(
|
||||||
f"[TRI] timing_e2e_triangle_ms={_e2e:.1f} "
|
f"[TRI] timing_e2e_triangle_ms={_e2e:.1f} "
|
||||||
@@ -280,6 +299,16 @@ def analyze_shot(frame, laser_point=None):
|
|||||||
"tri_markers_completed": tri.get("markers_completed", []),
|
"tri_markers_completed": tri.get("markers_completed", []),
|
||||||
"tri_homography": tri.get("homography"),
|
"tri_homography": tri.get("homography"),
|
||||||
}
|
}
|
||||||
|
try:
|
||||||
|
import numpy as _np
|
||||||
|
_H = tri.get("homography")
|
||||||
|
if _H is not None and _np.all(_np.isfinite(_H)):
|
||||||
|
_H_inv = _np.linalg.inv(_H)
|
||||||
|
_pt = _np.array([[[0.0, 0.0]]], dtype=_np.float32)
|
||||||
|
_center_pt = cv2.perspectiveTransform(_pt, _H_inv)[0][0]
|
||||||
|
out["tri_center_px"] = [float(_center_pt[0]), float(_center_pt[1])]
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
if yolo_roi_xyxy is not None:
|
if yolo_roi_xyxy is not None:
|
||||||
out["yolo_roi_xyxy"] = yolo_roi_xyxy
|
out["yolo_roi_xyxy"] = yolo_roi_xyxy
|
||||||
return out
|
return out
|
||||||
@@ -318,11 +347,22 @@ def process_shot(adc_val):
|
|||||||
:return: None
|
:return: None
|
||||||
"""
|
"""
|
||||||
logger = logger_manager.logger
|
logger = logger_manager.logger
|
||||||
|
_timing_on = bool(getattr(config, "ARCHERY_TIMING_ENABLE", True))
|
||||||
|
|
||||||
try:
|
try:
|
||||||
network_manager.safe_enqueue({"shoot_event": "start"}, msg_type=2, high=True)
|
network_manager.safe_enqueue({"shoot_event": "start"}, msg_type=2, high=True)
|
||||||
frame = camera_manager.read_frame()
|
frame = camera_manager.read_frame()
|
||||||
|
|
||||||
|
from shot_id_generator import shot_id_generator
|
||||||
|
shot_id = shot_id_generator.generate_id()
|
||||||
|
|
||||||
|
if getattr(config, "SAVE_RAW_SHOT_IMAGE_ENABLED", False):
|
||||||
|
enqueue_save_raw_shot(
|
||||||
|
frame,
|
||||||
|
shot_id=shot_id,
|
||||||
|
photo_dir=config.PHOTO_DIR if config.SAVE_IMAGE_ENABLED else None,
|
||||||
|
)
|
||||||
|
|
||||||
# 调用算法分析
|
# 调用算法分析
|
||||||
analysis_result = analyze_shot(frame)
|
analysis_result = analyze_shot(frame)
|
||||||
|
|
||||||
@@ -356,6 +396,107 @@ def process_shot(adc_val):
|
|||||||
)
|
)
|
||||||
x, y = laser_point
|
x, y = laser_point
|
||||||
|
|
||||||
|
# 物方采样调试(config.TRIANGLE_SAMPLE_ENABLE):靶心为原点,取两个对称点判断黑白来区分 40/20 标靶
|
||||||
|
# 逻辑:若两个采样点 RGB 均 < 阈值 → 全黑 → 40cm 标靶;否则 → 20cm 标靶
|
||||||
|
sample_target_type = None
|
||||||
|
_t_sample = time_std.perf_counter() if _timing_on else None
|
||||||
|
_t_sample_ms = 0.0
|
||||||
|
sample_points = []
|
||||||
|
sample_patch_half = 2
|
||||||
|
if bool(getattr(config, "TRIANGLE_SAMPLE_ENABLE", False)):
|
||||||
|
sample_obj_radius_cm = float(getattr(config, "TRIANGLE_SAMPLE_RADIUS_CM", 15.0))
|
||||||
|
sample_obj_angles_deg = (0, 180) # 只取两个对称点:+X 和 -X
|
||||||
|
sample_patch_half = int(getattr(config, "TRIANGLE_SAMPLE_PATCH_HALF_PX", 2))
|
||||||
|
sample_black_thresh = float(getattr(config, "TRIANGLE_SAMPLE_BLACK_THRESH", 30.0))
|
||||||
|
try:
|
||||||
|
import math as _math
|
||||||
|
import numpy as _np
|
||||||
|
import cv2 as _cv2
|
||||||
|
|
||||||
|
if tri_homography is not None:
|
||||||
|
_H_inv = _np.linalg.inv(tri_homography)
|
||||||
|
for _ang in sample_obj_angles_deg:
|
||||||
|
_rad = _math.radians(float(_ang))
|
||||||
|
_pt_obj = _np.array([
|
||||||
|
[[sample_obj_radius_cm * _math.cos(_rad), sample_obj_radius_cm * _math.sin(_rad)]]
|
||||||
|
], dtype=_np.float32)
|
||||||
|
_pt_img = _cv2.perspectiveTransform(_pt_obj, _H_inv)[0][0]
|
||||||
|
_px, _py = float(_pt_img[0]), float(_pt_img[1])
|
||||||
|
sample_points.append({
|
||||||
|
"angle_deg": float(_ang),
|
||||||
|
"obj_cm": (float(sample_obj_radius_cm * _math.cos(_rad)), float(sample_obj_radius_cm * _math.sin(_rad))),
|
||||||
|
"img_px": (int(round(_px)), int(round(_py))),
|
||||||
|
})
|
||||||
|
elif center and radius:
|
||||||
|
_px_per_cm = float(radius) / 10.0
|
||||||
|
for _ang in sample_obj_angles_deg:
|
||||||
|
_rad = _math.radians(float(_ang))
|
||||||
|
_px = float(center[0]) + sample_obj_radius_cm * _math.cos(_rad) * _px_per_cm
|
||||||
|
_py = float(center[1]) + sample_obj_radius_cm * _math.sin(_rad) * _px_per_cm
|
||||||
|
sample_points.append({
|
||||||
|
"angle_deg": float(_ang),
|
||||||
|
"obj_cm": (float(sample_obj_radius_cm * _math.cos(_rad)), float(sample_obj_radius_cm * _math.sin(_rad))),
|
||||||
|
"img_px": (int(round(_px)), int(round(_py))),
|
||||||
|
})
|
||||||
|
|
||||||
|
# 取样后立即读像素并判断黑白:三角成功用 H_inv;三角失败但圆心成功用 center/radius 近似物方半径
|
||||||
|
_all_black = False
|
||||||
|
_sample_infos = []
|
||||||
|
if sample_points:
|
||||||
|
_img_cv_for_sample = image.image2cv(result_img, False, False)
|
||||||
|
_all_black = True
|
||||||
|
for _sp in sample_points:
|
||||||
|
_sx, _sy = _sp["img_px"]
|
||||||
|
_hh = max(1, sample_patch_half)
|
||||||
|
_patch = []
|
||||||
|
for _yy in range(_sy - _hh, _sy + _hh + 1):
|
||||||
|
if _yy < 0 or _yy >= _img_cv_for_sample.shape[0]:
|
||||||
|
continue
|
||||||
|
for _xx in range(_sx - _hh, _sx + _hh + 1):
|
||||||
|
if _xx < 0 or _xx >= _img_cv_for_sample.shape[1]:
|
||||||
|
continue
|
||||||
|
_patch.append(_img_cv_for_sample[_yy, _xx].astype(float))
|
||||||
|
if _patch:
|
||||||
|
_mean_rgb = _np.mean(_patch, axis=0)
|
||||||
|
_is_black = bool(_mean_rgb[0] < sample_black_thresh
|
||||||
|
and _mean_rgb[1] < sample_black_thresh
|
||||||
|
and _mean_rgb[2] < sample_black_thresh)
|
||||||
|
if not _is_black:
|
||||||
|
_all_black = False
|
||||||
|
_sample_infos.append(
|
||||||
|
f"{int(_sp['angle_deg'])}°@{_sx},{_sy} rgb=({int(_mean_rgb[0])},{int(_mean_rgb[1])},{int(_mean_rgb[2])})"
|
||||||
|
)
|
||||||
|
sample_target_type = "40cm_black" if _all_black else "20cm"
|
||||||
|
if _sample_infos:
|
||||||
|
logger.info("[采样] " + " | ".join(_sample_infos) + f" → {sample_target_type}")
|
||||||
|
except Exception as _e_sample:
|
||||||
|
sample_points = []
|
||||||
|
if logger:
|
||||||
|
logger.warning(f"[采样] 标靶类型判断失败: {_e_sample}")
|
||||||
|
if _timing_on and _t_sample is not None:
|
||||||
|
_t_sample_ms = (time_std.perf_counter() - _t_sample) * 1000.0
|
||||||
|
|
||||||
|
# 采样提前完成后,先确定靶型对应的物理半径,供后续距离/偏移/上报使用。
|
||||||
|
# 40cm_black 表示直径40cm,半径20cm;20cm 表示直径20cm,半径10cm。
|
||||||
|
target_radius_cm = 20.0 if sample_target_type == "40cm_black" else (10.0 if sample_target_type == "20cm" else 20.0)
|
||||||
|
target_type_value = 40 if sample_target_type == "40cm_black" else (20 if sample_target_type == "20cm" else None)
|
||||||
|
|
||||||
|
# 圆心分支原算法默认按40cm靶半径20cm换算;若采样判定为20cm靶,在上报前修正距离和偏移。
|
||||||
|
# 三角分支使用 triangle_positions.json 的物方坐标,不在这里二次缩放,避免影响三角单应性结果。
|
||||||
|
if sample_target_type == "20cm" and center and radius and not tri_markers:
|
||||||
|
try:
|
||||||
|
distance_m = (target_radius_cm * config.FOCAL_LENGTH_PIX) / float(radius) / 100.0
|
||||||
|
_scale = target_radius_cm / 20.0
|
||||||
|
if dx is not None:
|
||||||
|
dx = float(dx) * _scale
|
||||||
|
if dy is not None:
|
||||||
|
dy = float(dy) * _scale
|
||||||
|
if logger:
|
||||||
|
logger.info(f"[采样] 20cm靶修正圆心测距/偏移: distance={distance_m:.2f}m scale={_scale:.2f}")
|
||||||
|
except Exception as _e_fix:
|
||||||
|
if logger:
|
||||||
|
logger.warning(f"[采样] 20cm靶修正失败: {_e_fix}")
|
||||||
|
|
||||||
# 三角形路径成功时 center/radius 为空是正常的;此时用 triangle 方法名用于保存文件名与上报字段 m
|
# 三角形路径成功时 center/radius 为空是正常的;此时用 triangle 方法名用于保存文件名与上报字段 m
|
||||||
if (not method) and tri_markers:
|
if (not method) and tri_markers:
|
||||||
method = "triangle_homography"
|
method = "triangle_homography"
|
||||||
@@ -366,10 +507,6 @@ def process_shot(adc_val):
|
|||||||
if dx is None and dy is None and logger:
|
if dx is None and dy is None and logger:
|
||||||
logger.warning("[MAIN] 未检测到偏移量(三角形与圆形均失败),但会保存图像")
|
logger.warning("[MAIN] 未检测到偏移量(三角形与圆形均失败),但会保存图像")
|
||||||
|
|
||||||
# 生成射箭ID
|
|
||||||
from shot_id_generator import shot_id_generator
|
|
||||||
shot_id = shot_id_generator.generate_id()
|
|
||||||
|
|
||||||
if logger:
|
if logger:
|
||||||
logger.info(f"[MAIN] 射箭ID: {shot_id}")
|
logger.info(f"[MAIN] 射箭ID: {shot_id}")
|
||||||
|
|
||||||
@@ -386,7 +523,7 @@ def process_shot(adc_val):
|
|||||||
"shot_id": shot_id,
|
"shot_id": shot_id,
|
||||||
"x": srv_x,
|
"x": srv_x,
|
||||||
"y": srv_y,
|
"y": srv_y,
|
||||||
"r": 20.0, # 保留字段(服务端当前忽略,物理外环半径 cm)
|
"r": target_radius_cm, # 物理靶半径 cm:40cm靶=20,20cm靶=10
|
||||||
"d": round((distance_m or 0.0) * 100),
|
"d": round((distance_m or 0.0) * 100),
|
||||||
"d_laser": round((laser_distance_m or 0.0) * 100),
|
"d_laser": round((laser_distance_m or 0.0) * 100),
|
||||||
"d_laser_quality": laser_signal_quality,
|
"d_laser_quality": laser_signal_quality,
|
||||||
@@ -397,6 +534,7 @@ def process_shot(adc_val):
|
|||||||
"target_y": float(y),
|
"target_y": float(y),
|
||||||
"offset_method": offset_method,
|
"offset_method": offset_method,
|
||||||
"distance_method": distance_method,
|
"distance_method": distance_method,
|
||||||
|
"target_type": target_type_value,
|
||||||
}
|
}
|
||||||
|
|
||||||
if ellipse_params:
|
if ellipse_params:
|
||||||
@@ -471,6 +609,11 @@ def process_shot(adc_val):
|
|||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
# 物方采样标靶类型判断耗时(合并在上面采样块内,单独统计)
|
||||||
|
if _timing_on and bool(getattr(config, "TRIANGLE_SAMPLE_ENABLE", False)) and sample_target_type is not None:
|
||||||
|
logger.info(f"[采样] 标靶类型: {sample_target_type} 耗时: {_t_sample_ms:.2f}ms")
|
||||||
|
|
||||||
|
|
||||||
# 叠加信息:落点-圆心距离 / 相机-靶距离等
|
# 叠加信息:落点-圆心距离 / 相机-靶距离等
|
||||||
try:
|
try:
|
||||||
import math as _math
|
import math as _math
|
||||||
|
|||||||
403
test/test_algo_preview_live.py
Normal file
403
test/test_algo_preview_live.py
Normal file
@@ -0,0 +1,403 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
"""
|
||||||
|
实时摄像头预览:叠加与射箭存图相同的算法标注(YOLO ROI、三角/圆心、激光十字等),默认不写盘。
|
||||||
|
|
||||||
|
在 MaixCAM 上从项目根目录运行:
|
||||||
|
python3 test/test_algo_preview_live.py
|
||||||
|
python3 test/test_algo_preview_live.py --interval 1.5
|
||||||
|
python3 test/test_algo_preview_live.py --every-frame
|
||||||
|
|
||||||
|
说明:
|
||||||
|
- 完整算法走 shoot_manager.analyze_shot(与 process_shot 一致,含 YOLO + 三角/圆心)。
|
||||||
|
- 画面标注对齐 process_shot 存图前绘制 + vision._draw_yolo_roi_on_rgb_numpy / 圆心存图线。
|
||||||
|
- 预览模式会关闭 Stage2 裁切 JPEG 落盘,避免写满 /root/phot。
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import math
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
import threading
|
||||||
|
import time
|
||||||
|
|
||||||
|
_ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||||||
|
if _ROOT not in sys.path:
|
||||||
|
sys.path.insert(0, _ROOT)
|
||||||
|
|
||||||
|
import cv2
|
||||||
|
import numpy as np
|
||||||
|
from maix import image, time as maix_time
|
||||||
|
|
||||||
|
import config
|
||||||
|
from camera_manager import camera_manager
|
||||||
|
from laser_manager import laser_manager
|
||||||
|
from shoot_manager import analyze_shot, preload_triangle_calib
|
||||||
|
from target_roi_yolo import preload_yolo_detector
|
||||||
|
from vision import _draw_yolo_roi_on_rgb_numpy
|
||||||
|
|
||||||
|
|
||||||
|
def _copy_maix_frame(frame):
|
||||||
|
"""相机下一帧可能复用缓冲区,异步分析前先复制。"""
|
||||||
|
img_cv = image.image2cv(frame, False, False)
|
||||||
|
return image.cv2image(np.ascontiguousarray(img_cv), False, False)
|
||||||
|
|
||||||
|
|
||||||
|
def _patch_preview_config():
|
||||||
|
"""预览不写调试 JPEG,避免刷屏占存储。"""
|
||||||
|
config.TRIANGLE_BLACK_YOLO_SAVE_ROI_CROP = False
|
||||||
|
config.TRIANGLE_SAVE_DEBUG_IMAGE = False
|
||||||
|
|
||||||
|
|
||||||
|
def _annotate_like_saved_shot(analysis: dict):
|
||||||
|
"""
|
||||||
|
将 analyze_shot 结果绘制成与 process_shot -> enqueue_save_shot 存盘前一致的 Maix 图。
|
||||||
|
"""
|
||||||
|
result_img = analysis.get("result_img")
|
||||||
|
if result_img is None:
|
||||||
|
return None
|
||||||
|
|
||||||
|
center = analysis.get("center")
|
||||||
|
radius = analysis.get("radius")
|
||||||
|
method = analysis.get("method")
|
||||||
|
ellipse_params = analysis.get("ellipse_params")
|
||||||
|
laser_point = analysis.get("laser_point")
|
||||||
|
dx = analysis.get("dx")
|
||||||
|
dy = analysis.get("dy")
|
||||||
|
distance_m = analysis.get("distance_m")
|
||||||
|
offset_method = analysis.get("offset_method", "")
|
||||||
|
distance_method = analysis.get("distance_method", "")
|
||||||
|
tri_markers = analysis.get("tri_markers") or []
|
||||||
|
tri_markers_completed = analysis.get("tri_markers_completed") or []
|
||||||
|
tri_homography = analysis.get("tri_homography")
|
||||||
|
yolo_roi_xyxy = analysis.get("yolo_roi_xyxy")
|
||||||
|
|
||||||
|
if laser_point is None:
|
||||||
|
return result_img
|
||||||
|
|
||||||
|
x, y = laser_point
|
||||||
|
draw_yolo_roi = (
|
||||||
|
yolo_roi_xyxy is not None
|
||||||
|
and getattr(config, "TRIANGLE_YOLO_DRAW_ROI_ON_SHOT", True)
|
||||||
|
)
|
||||||
|
|
||||||
|
if tri_markers:
|
||||||
|
img_cv = image.image2cv(result_img, False, False).copy()
|
||||||
|
|
||||||
|
if draw_yolo_roi:
|
||||||
|
_draw_yolo_roi_on_rgb_numpy(img_cv, yolo_roi_xyxy)
|
||||||
|
|
||||||
|
for m in tri_markers:
|
||||||
|
corners = np.array(m["corners"], dtype=np.int32)
|
||||||
|
cv2.polylines(img_cv, [corners], True, (0, 255, 0), 2)
|
||||||
|
cx, cy = int(m["center"][0]), int(m["center"][1])
|
||||||
|
cv2.circle(img_cv, (cx, cy), 4, (0, 0, 255), -1)
|
||||||
|
cv2.putText(
|
||||||
|
img_cv,
|
||||||
|
f"T{m['id']}",
|
||||||
|
(cx - 18, cy - 12),
|
||||||
|
cv2.FONT_HERSHEY_SIMPLEX,
|
||||||
|
0.55,
|
||||||
|
(0, 255, 0),
|
||||||
|
1,
|
||||||
|
)
|
||||||
|
|
||||||
|
for m in tri_markers_completed:
|
||||||
|
if not m.get("is_virtual"):
|
||||||
|
continue
|
||||||
|
cx, cy = int(m["center"][0]), int(m["center"][1])
|
||||||
|
cv2.circle(img_cv, (cx, cy), 6, (255, 0, 255), 2)
|
||||||
|
cv2.putText(
|
||||||
|
img_cv,
|
||||||
|
f"VT{m['id']}",
|
||||||
|
(cx - 22, cy - 12),
|
||||||
|
cv2.FONT_HERSHEY_SIMPLEX,
|
||||||
|
0.55,
|
||||||
|
(255, 0, 255),
|
||||||
|
1,
|
||||||
|
)
|
||||||
|
|
||||||
|
if tri_homography is not None:
|
||||||
|
try:
|
||||||
|
H_inv = np.linalg.inv(tri_homography)
|
||||||
|
c_img = cv2.perspectiveTransform(
|
||||||
|
np.array([[[0.0, 0.0]]], dtype=np.float32), H_inv
|
||||||
|
)[0][0]
|
||||||
|
ocx, ocy = int(c_img[0]), int(c_img[1])
|
||||||
|
cv2.circle(img_cv, (ocx, ocy), 5, (0, 0, 255), -1)
|
||||||
|
cv2.circle(img_cv, (ocx, ocy), 9, (0, 0, 255), 1)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
lines = []
|
||||||
|
if dx is not None and dy is not None:
|
||||||
|
r_cm = math.hypot(float(dx), float(dy))
|
||||||
|
lines.append(f"offset=({float(dx):.2f},{float(dy):.2f})cm |r|={r_cm:.2f}cm")
|
||||||
|
if distance_m is not None:
|
||||||
|
lines.append(f"cam_dist={float(distance_m):.2f}m ({distance_method})")
|
||||||
|
if method:
|
||||||
|
lines.append(f"method={method} ({offset_method})")
|
||||||
|
y0 = 22
|
||||||
|
for i, t in enumerate(lines):
|
||||||
|
cv2.putText(
|
||||||
|
img_cv,
|
||||||
|
t,
|
||||||
|
(10, y0 + i * 18),
|
||||||
|
cv2.FONT_HERSHEY_SIMPLEX,
|
||||||
|
0.5,
|
||||||
|
(0, 255, 0),
|
||||||
|
1,
|
||||||
|
)
|
||||||
|
|
||||||
|
out = image.cv2image(img_cv, False, False)
|
||||||
|
else:
|
||||||
|
img_cv = image.image2cv(result_img, False, False).copy()
|
||||||
|
if draw_yolo_roi:
|
||||||
|
_draw_yolo_roi_on_rgb_numpy(img_cv, yolo_roi_xyxy)
|
||||||
|
|
||||||
|
if center and radius:
|
||||||
|
cx, cy = center
|
||||||
|
if ellipse_params:
|
||||||
|
(ell_center, (width, height), angle) = ellipse_params
|
||||||
|
cx_ell, cy_ell = int(ell_center[0]), int(ell_center[1])
|
||||||
|
cv2.ellipse(
|
||||||
|
img_cv,
|
||||||
|
(cx_ell, cy_ell),
|
||||||
|
(int(width / 2), int(height / 2)),
|
||||||
|
angle,
|
||||||
|
0,
|
||||||
|
360,
|
||||||
|
(0, 255, 0),
|
||||||
|
2,
|
||||||
|
)
|
||||||
|
cv2.circle(img_cv, (cx_ell, cy_ell), 3, (255, 0, 0), -1)
|
||||||
|
minor_length = min(width, height) / 2
|
||||||
|
minor_angle = angle + 90 if width >= height else angle
|
||||||
|
minor_angle_rad = math.radians(minor_angle)
|
||||||
|
dx_minor = minor_length * math.cos(minor_angle_rad)
|
||||||
|
dy_minor = minor_length * math.sin(minor_angle_rad)
|
||||||
|
pt1 = (int(cx_ell - dx_minor), int(cy_ell - dy_minor))
|
||||||
|
pt2 = (int(cx_ell + dx_minor), int(cy_ell + dy_minor))
|
||||||
|
cv2.line(img_cv, pt1, pt2, (0, 0, 255), 2)
|
||||||
|
else:
|
||||||
|
cv2.circle(img_cv, (int(cx), int(cy)), int(radius), (0, 0, 255), 2)
|
||||||
|
cv2.circle(img_cv, (int(cx), int(cy)), 2, (0, 0, 255), -1)
|
||||||
|
cv2.line(img_cv, (int(x), int(y)), (int(cx), int(cy)), (255, 255, 0), 1)
|
||||||
|
|
||||||
|
lines = []
|
||||||
|
if dx is not None and dy is not None:
|
||||||
|
lines.append(f"offset=({float(dx):.2f},{float(dy):.2f})cm")
|
||||||
|
if distance_m is not None:
|
||||||
|
lines.append(f"dist={float(distance_m):.2f}m ({distance_method})")
|
||||||
|
if method:
|
||||||
|
lines.append(f"method={method}")
|
||||||
|
for i, t in enumerate(lines):
|
||||||
|
cv2.putText(
|
||||||
|
img_cv,
|
||||||
|
t,
|
||||||
|
(10, 22 + i * 18),
|
||||||
|
cv2.FONT_HERSHEY_SIMPLEX,
|
||||||
|
0.5,
|
||||||
|
(0, 255, 0),
|
||||||
|
1,
|
||||||
|
)
|
||||||
|
out = image.cv2image(img_cv, False, False)
|
||||||
|
|
||||||
|
lc = image.Color(config.LASER_COLOR[0], config.LASER_COLOR[1], config.LASER_COLOR[2])
|
||||||
|
out.draw_line(
|
||||||
|
int(x - config.LASER_LENGTH),
|
||||||
|
int(y),
|
||||||
|
int(x + config.LASER_LENGTH),
|
||||||
|
int(y),
|
||||||
|
lc,
|
||||||
|
config.LASER_THICKNESS,
|
||||||
|
)
|
||||||
|
out.draw_line(
|
||||||
|
int(x),
|
||||||
|
int(y - config.LASER_LENGTH),
|
||||||
|
int(x),
|
||||||
|
int(y + config.LASER_LENGTH),
|
||||||
|
lc,
|
||||||
|
config.LASER_THICKNESS,
|
||||||
|
)
|
||||||
|
out.draw_circle(int(x), int(y), 1, lc, config.LASER_THICKNESS)
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
class _AlgoWorker:
|
||||||
|
def __init__(self):
|
||||||
|
self._lock = threading.Lock()
|
||||||
|
self._busy = False
|
||||||
|
self._latest_preview = None
|
||||||
|
self._latest_meta = ""
|
||||||
|
self._last_ms = 0.0
|
||||||
|
|
||||||
|
@property
|
||||||
|
def busy(self):
|
||||||
|
with self._lock:
|
||||||
|
return self._busy
|
||||||
|
|
||||||
|
@property
|
||||||
|
def last_ms(self):
|
||||||
|
with self._lock:
|
||||||
|
return self._last_ms
|
||||||
|
|
||||||
|
def get_preview(self):
|
||||||
|
with self._lock:
|
||||||
|
return self._latest_preview, self._latest_meta
|
||||||
|
|
||||||
|
def run_async(self, frame):
|
||||||
|
with self._lock:
|
||||||
|
if self._busy:
|
||||||
|
return False
|
||||||
|
self._busy = True
|
||||||
|
|
||||||
|
def _job():
|
||||||
|
t0 = time.perf_counter()
|
||||||
|
meta = ""
|
||||||
|
preview = None
|
||||||
|
try:
|
||||||
|
analysis = analyze_shot(frame)
|
||||||
|
if not analysis.get("success"):
|
||||||
|
reason = analysis.get("reason", "unknown")
|
||||||
|
meta = f"fail:{reason}"
|
||||||
|
else:
|
||||||
|
preview = _annotate_like_saved_shot(analysis)
|
||||||
|
dx, dy = analysis.get("dx"), analysis.get("dy")
|
||||||
|
method = analysis.get("method") or "?"
|
||||||
|
if dx is not None and dy is not None:
|
||||||
|
meta = f"ok {method} ({dx:.2f},{dy:.2f})cm"
|
||||||
|
else:
|
||||||
|
meta = f"ok {method} no_offset"
|
||||||
|
except Exception as e:
|
||||||
|
meta = f"err:{e}"
|
||||||
|
elapsed = (time.perf_counter() - t0) * 1000.0
|
||||||
|
with self._lock:
|
||||||
|
self._latest_preview = preview
|
||||||
|
self._latest_meta = f"{meta} {elapsed:.0f}ms"
|
||||||
|
self._last_ms = elapsed
|
||||||
|
self._busy = False
|
||||||
|
|
||||||
|
threading.Thread(target=_job, daemon=True).start()
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
def _draw_status(frame, lines, color=None):
|
||||||
|
if color is None:
|
||||||
|
color = image.COLOR_YELLOW
|
||||||
|
y = 4
|
||||||
|
for line in lines:
|
||||||
|
frame.draw_string(4, y, line, color=color)
|
||||||
|
y += 16
|
||||||
|
|
||||||
|
|
||||||
|
def _save_preview_jpeg(maix_img, out_dir):
|
||||||
|
os.makedirs(out_dir, exist_ok=True)
|
||||||
|
fn = os.path.join(out_dir, f"preview_{int(time.time() * 1000)}.jpg")
|
||||||
|
maix_img.save(fn)
|
||||||
|
return fn
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
parser = argparse.ArgumentParser(description="实时预览射箭算法存图效果")
|
||||||
|
parser.add_argument(
|
||||||
|
"--interval",
|
||||||
|
type=float,
|
||||||
|
default=2.0,
|
||||||
|
help="两次完整 analyze_shot 的最小间隔(秒);--every-frame 时忽略",
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--every-frame",
|
||||||
|
action="store_true",
|
||||||
|
help="每帧都触发算法(很慢,仅调试用)",
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--width",
|
||||||
|
type=int,
|
||||||
|
default=getattr(config, "CAMERA_WIDTH", 640),
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--height",
|
||||||
|
type=int,
|
||||||
|
default=getattr(config, "CAMERA_HEIGHT", 480),
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--save-dir",
|
||||||
|
default=config.PHOTO_DIR,
|
||||||
|
help="按板子按键无;用 --save-every N 每 N 次成功分析存一张",
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--save-every",
|
||||||
|
type=int,
|
||||||
|
default=0,
|
||||||
|
help="每成功分析 N 次自动存一张到 --save-dir(0=不自动存)",
|
||||||
|
)
|
||||||
|
args = parser.parse_args()
|
||||||
|
|
||||||
|
_patch_preview_config()
|
||||||
|
print("[INFO] 预览模式:已关闭 TRIANGLE_BLACK_YOLO_SAVE_ROI_CROP / TRIANGLE_SAVE_DEBUG_IMAGE")
|
||||||
|
|
||||||
|
laser_manager.load_laser_point()
|
||||||
|
preload_triangle_calib()
|
||||||
|
if getattr(config, "TRIANGLE_YOLO_PRELOAD_ON_BOOT", False) or getattr(
|
||||||
|
config, "TRIANGLE_BLACK_YOLO_PRELOAD_ON_BOOT", False
|
||||||
|
):
|
||||||
|
print("[INFO] 预加载 YOLO …")
|
||||||
|
preload_yolo_detector()
|
||||||
|
|
||||||
|
camera_manager.init_camera(args.width, args.height)
|
||||||
|
camera_manager.init_display()
|
||||||
|
worker = _AlgoWorker()
|
||||||
|
|
||||||
|
interval_s = 0.0 if args.every_frame else max(0.3, float(args.interval))
|
||||||
|
last_trigger = 0.0
|
||||||
|
ok_count = 0
|
||||||
|
frame_idx = 0
|
||||||
|
|
||||||
|
print(
|
||||||
|
f"[INFO] 摄像头 {args.width}x{args.height} "
|
||||||
|
f"interval={'每帧' if args.every_frame else f'{interval_s}s'}"
|
||||||
|
)
|
||||||
|
print("[INFO] 退出:Ctrl+C")
|
||||||
|
|
||||||
|
try:
|
||||||
|
while True:
|
||||||
|
frame = camera_manager.read_frame()
|
||||||
|
frame_idx += 1
|
||||||
|
now = time.perf_counter()
|
||||||
|
|
||||||
|
due = args.every_frame or (now - last_trigger >= interval_s)
|
||||||
|
if due and not worker.busy:
|
||||||
|
last_trigger = now
|
||||||
|
worker.run_async(_copy_maix_frame(frame))
|
||||||
|
|
||||||
|
preview, meta = worker.get_preview()
|
||||||
|
if preview is not None:
|
||||||
|
show_img = preview
|
||||||
|
status = [f"#{frame_idx}", meta]
|
||||||
|
if args.save_every > 0 and meta.startswith("ok"):
|
||||||
|
ok_count += 1
|
||||||
|
if ok_count % args.save_every == 0:
|
||||||
|
try:
|
||||||
|
fn = _save_preview_jpeg(preview, args.save_dir)
|
||||||
|
status.append(f"saved:{fn}")
|
||||||
|
except Exception as e:
|
||||||
|
status.append(f"save_err:{e}")
|
||||||
|
else:
|
||||||
|
show_img = frame
|
||||||
|
if worker.busy:
|
||||||
|
status = [f"#{frame_idx}", "analyzing…"]
|
||||||
|
else:
|
||||||
|
status = [f"#{frame_idx}", "waiting…"]
|
||||||
|
|
||||||
|
_draw_status(show_img, status)
|
||||||
|
camera_manager.show(show_img)
|
||||||
|
maix_time.sleep_ms(1)
|
||||||
|
except KeyboardInterrupt:
|
||||||
|
print("[INFO] 已退出")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
@@ -1,330 +0,0 @@
|
|||||||
#!/usr/bin/env python3
|
|
||||||
# -*- coding: utf-8 -*-
|
|
||||||
"""
|
|
||||||
离线测试脚本:直接复用 detect_circle 逻辑进行测试
|
|
||||||
运行环境:MaixPy (Sipeed MAIX)
|
|
||||||
"""
|
|
||||||
import sys
|
|
||||||
import os
|
|
||||||
# import time
|
|
||||||
from maix import image, time
|
|
||||||
import cv2
|
|
||||||
import numpy as np
|
|
||||||
import math
|
|
||||||
|
|
||||||
# ==================== 全局配置 (与 test_main.py 保持一致) ====================
|
|
||||||
REAL_RADIUS_CM = 20 # 靶心实际半径(厘米)
|
|
||||||
|
|
||||||
def detect_circle_v3(frame, laser_point=None, img_cv=None):
|
|
||||||
"""检测图像中的靶心(优先清晰轮廓,其次黄色区域)- 返回椭圆参数版本
|
|
||||||
增加红色圆圈检测,验证黄色圆圈是否为真正的靶心
|
|
||||||
如果提供 laser_point,会选择最接近激光点的目标
|
|
||||||
优化:
|
|
||||||
1. 缩图到 MAX_DET_DIM 后再做 HSV/形态学,最长边 640->320 可获得 ~4x 加速
|
|
||||||
2. 红色掩码在黄色轮廓循环外只计算一次,避免 N 次重复计算
|
|
||||||
3. img_cv 可由外部传入(与其他线程共享转换结果),为 None 时自动转换
|
|
||||||
Args:
|
|
||||||
frame: 图像帧(img_cv 为 None 时使用)
|
|
||||||
laser_point: 激光点坐标 (x, y),用于多目标场景下的目标选择
|
|
||||||
img_cv: 已转换的 numpy BGR/RGB 图像;不为 None 时跳过 image2cv 转换
|
|
||||||
Returns:
|
|
||||||
(result_img, best_center, best_radius, method, best_radius1, ellipse_params)
|
|
||||||
"""
|
|
||||||
if img_cv is None:
|
|
||||||
img_cv = image.image2cv(frame, False, False)
|
|
||||||
from datetime import datetime
|
|
||||||
print(f"[detect_circle_v3] begin {datetime.now()}")
|
|
||||||
# -- 1. 缩图加速(与三角形路径保持一致)
|
|
||||||
h_orig, w_orig = img_cv.shape[:2]
|
|
||||||
MAX_DET_DIM = 480
|
|
||||||
long_side = max(h_orig, w_orig)
|
|
||||||
if long_side > MAX_DET_DIM:
|
|
||||||
det_scale = MAX_DET_DIM / long_side
|
|
||||||
img_det = cv2.resize(img_cv, (int(w_orig * det_scale), int(h_orig * det_scale)),
|
|
||||||
interpolation=cv2.INTER_LINEAR)
|
|
||||||
inv_scale = 1.0 / det_scale # 检测坐标 -> 原始坐标的倍率
|
|
||||||
else:
|
|
||||||
img_det = img_cv
|
|
||||||
inv_scale = 1.0
|
|
||||||
|
|
||||||
# 激光点映射到检测分辨率
|
|
||||||
lp_det = None
|
|
||||||
if laser_point is not None:
|
|
||||||
lp_det = (laser_point[0] / inv_scale, laser_point[1] / inv_scale)
|
|
||||||
best_center = best_radius = best_radius1 = method = None
|
|
||||||
ellipse_params = None
|
|
||||||
|
|
||||||
print(f"[detect_circle_v3] step 1 fin {datetime.now()}")
|
|
||||||
|
|
||||||
# -- 2. HSV + 黄色掩码
|
|
||||||
hsv = cv2.cvtColor(img_det, cv2.COLOR_RGB2HSV)
|
|
||||||
h, s, v = cv2.split(hsv)
|
|
||||||
s = np.clip(s * 1.1, 0, 255).astype(np.uint8)
|
|
||||||
hsv = cv2.merge((h, s, v))
|
|
||||||
lower_yellow = np.array([7, 80, 0])
|
|
||||||
upper_yellow = np.array([32, 255, 255])
|
|
||||||
mask_yellow = cv2.inRange(hsv, lower_yellow, upper_yellow)
|
|
||||||
kernel = cv2.getStructuringElement(cv2.MORPH_ELLIPSE, (5, 5))
|
|
||||||
mask_yellow = cv2.morphologyEx(mask_yellow, cv2.MORPH_CLOSE, kernel)
|
|
||||||
|
|
||||||
print(f"[detect_circle_v3] step 2 fin {datetime.now()}")
|
|
||||||
|
|
||||||
# -- 3. 红色掩码:在循环外只算一次
|
|
||||||
mask_red = cv2.bitwise_or(
|
|
||||||
cv2.inRange(hsv, np.array([0, 50, 40]), np.array([10, 255, 255])),
|
|
||||||
cv2.inRange(hsv, np.array([170, 50, 40]), np.array([180, 255, 255])),
|
|
||||||
)
|
|
||||||
kernel_red = cv2.getStructuringElement(cv2.MORPH_ELLIPSE, (5, 5))
|
|
||||||
mask_red = cv2.morphologyEx(mask_red, cv2.MORPH_CLOSE, kernel_red)
|
|
||||||
contours_red, _ = cv2.findContours(mask_red, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE)
|
|
||||||
# 预先把红色轮廓筛选成 (center, radius) 列表,后续直接查表
|
|
||||||
red_candidates = []
|
|
||||||
for cnt_r in contours_red:
|
|
||||||
ar = cv2.contourArea(cnt_r)
|
|
||||||
if ar <= 10:
|
|
||||||
continue
|
|
||||||
pr = cv2.arcLength(cnt_r, True)
|
|
||||||
if pr <= 0 or (4 * np.pi * ar) / (pr * pr) <= 0.3:
|
|
||||||
continue
|
|
||||||
if len(cnt_r) >= 5:
|
|
||||||
(xr, yr), (wr, hr), _ = cv2.fitEllipse(cnt_r)
|
|
||||||
red_candidates.append({"center": (int(xr), int(yr)), "radius": int(min(wr, hr) / 2)})
|
|
||||||
else:
|
|
||||||
(xr, yr), rr = cv2.minEnclosingCircle(cnt_r)
|
|
||||||
red_candidates.append({"center": (int(xr), int(yr)), "radius": int(rr)})
|
|
||||||
|
|
||||||
print(f"[detect_circle_v3] step 3 fin {datetime.now()}")
|
|
||||||
|
|
||||||
# -- 4. 黄色轮廓循环(复用上面的红色候选列表)
|
|
||||||
contours_yellow, _ = cv2.findContours(mask_yellow, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE)
|
|
||||||
valid_targets = []
|
|
||||||
for cnt_yellow in contours_yellow:
|
|
||||||
area = cv2.contourArea(cnt_yellow)
|
|
||||||
if area <= 15:
|
|
||||||
continue
|
|
||||||
perimeter = cv2.arcLength(cnt_yellow, True)
|
|
||||||
if perimeter <= 0:
|
|
||||||
continue
|
|
||||||
circularity = (4 * np.pi * area) / (perimeter * perimeter)
|
|
||||||
if circularity <= 0.5:
|
|
||||||
continue
|
|
||||||
print(f"[target] -> 面积:{area:.1f}, 圆度:{circularity:.2f}")
|
|
||||||
if len(cnt_yellow) >= 5:
|
|
||||||
(x, y), (width, height), angle = cv2.fitEllipse(cnt_yellow)
|
|
||||||
yellow_ellipse = ((x, y), (width, height), angle)
|
|
||||||
yellow_center = (int(x), int(y))
|
|
||||||
yellow_radius = int(min(width, height) / 2)
|
|
||||||
else:
|
|
||||||
(x, y), radius = cv2.minEnclosingCircle(cnt_yellow)
|
|
||||||
yellow_center = (int(x), int(y))
|
|
||||||
yellow_radius = int(radius)
|
|
||||||
yellow_ellipse = None
|
|
||||||
# 在预筛好的红色候选中匹配
|
|
||||||
matched = False
|
|
||||||
for rc in red_candidates:
|
|
||||||
ddx = yellow_center[0] - rc["center"][0]
|
|
||||||
ddy = yellow_center[1] - rc["center"][1]
|
|
||||||
dist_centers = math.hypot(ddx, ddy)
|
|
||||||
if dist_centers < yellow_radius * 1.5 and rc["radius"] > yellow_radius * 0.7:
|
|
||||||
print(f"[target] -> 找到匹配的红圈: 黄心({yellow_center}), "
|
|
||||||
f"红心({rc['center']}), 距离:{dist_centers:.1f}, "
|
|
||||||
f"黄半径:{yellow_radius}, 红半径:{rc['radius']}")
|
|
||||||
valid_targets.append({
|
|
||||||
"center": yellow_center,
|
|
||||||
"radius": yellow_radius,
|
|
||||||
"ellipse": yellow_ellipse,
|
|
||||||
"area": area,
|
|
||||||
})
|
|
||||||
matched = True
|
|
||||||
break
|
|
||||||
if not matched :
|
|
||||||
print("Debug -> 未找到匹配的红色圆圈,可能是误识别")
|
|
||||||
|
|
||||||
print(f"[detect_circle_v3] step 4 fin {datetime.now()}")
|
|
||||||
|
|
||||||
# -- 5. 选最佳目标,坐标还原到原始分辨率
|
|
||||||
if valid_targets:
|
|
||||||
if lp_det:
|
|
||||||
best_target = min(valid_targets,
|
|
||||||
key=lambda t: (t["center"][0] - lp_det[0]) ** 2
|
|
||||||
+ (t["center"][1] - lp_det[1]) ** 2)
|
|
||||||
method = "v3_ellipse_red_validated_laser_selected"
|
|
||||||
else:
|
|
||||||
best_target = max(valid_targets, key=lambda t: t["area"])
|
|
||||||
method = "v3_ellipse_red_validated"
|
|
||||||
bc = best_target["center"]
|
|
||||||
br = best_target["radius"]
|
|
||||||
be = best_target["ellipse"]
|
|
||||||
if inv_scale != 1.0:
|
|
||||||
best_center = (int(bc[0] * inv_scale), int(bc[1] * inv_scale))
|
|
||||||
best_radius = int(br * inv_scale)
|
|
||||||
if be is not None:
|
|
||||||
(ex, ey), (ew, eh), ea = be
|
|
||||||
be = ((ex * inv_scale, ey * inv_scale),
|
|
||||||
(ew * inv_scale, eh * inv_scale), ea)
|
|
||||||
else:
|
|
||||||
best_center = bc
|
|
||||||
best_radius = br
|
|
||||||
ellipse_params = be
|
|
||||||
best_radius1 = best_radius * 5
|
|
||||||
result_img = image.cv2image(img_cv, False, False)
|
|
||||||
print(f"[detect_circle_v3] step 5 fin {datetime.now()}")
|
|
||||||
return result_img, best_center, best_radius, method, best_radius1, ellipse_params
|
|
||||||
|
|
||||||
|
|
||||||
def run_offline_test(image_path):
|
|
||||||
"""读取图片,检测圆,绘制结果,保存图片"""
|
|
||||||
|
|
||||||
# 1. 检查文件是否存在
|
|
||||||
if not os.path.exists(image_path):
|
|
||||||
print(f"[ERROR] 找不到图片文件: {image_path}")
|
|
||||||
return
|
|
||||||
|
|
||||||
# 2. 使用 maix.image 读取图片 (适配 MaixPy v4)
|
|
||||||
try:
|
|
||||||
# 使用 image.load 读取文件,返回 Image 对象
|
|
||||||
img = image.load(image_path)
|
|
||||||
print(f"[INFO] 成功读取图片: {image_path} (尺寸: {img.width()}x{img.height()})")
|
|
||||||
except Exception as e:
|
|
||||||
print(f"[ERROR] 读取图片失败: {e}")
|
|
||||||
print("提示:请确认 MaixPy 版本是否为 v4,且图片路径正确。")
|
|
||||||
return
|
|
||||||
|
|
||||||
# 3. 调用 detect_circle_v3 函数
|
|
||||||
print("[INFO] 正在调用 detect_circle_v3 进行检测...")
|
|
||||||
start_time = time.ticks_ms()
|
|
||||||
|
|
||||||
result_img, center, radius, method, radius1, ellipse_params = detect_circle_v3(img)
|
|
||||||
|
|
||||||
cost_time = time.ticks_ms() - start_time
|
|
||||||
print(f"[INFO] 检测完成,耗时: {cost_time}ms")
|
|
||||||
print(f" 结果 -> 圆心: {center}, 半径: {radius}, 方法: {method}")
|
|
||||||
if ellipse_params:
|
|
||||||
(ell_center, (width, height), angle) = ellipse_params
|
|
||||||
print(
|
|
||||||
f" 椭圆 -> 中心: ({ell_center[0]:.1f}, {ell_center[1]:.1f}), 长轴: {max(width, height):.1f}, 短轴: {min(width, height):.1f}, 角度: {angle:.1f}°")
|
|
||||||
|
|
||||||
# 4. 绘制辅助线(可选,用于调试)
|
|
||||||
if center and radius:
|
|
||||||
# 为了绘制椭圆,需要转换回 cv2 图像
|
|
||||||
img_cv = image.image2cv(result_img, False, False)
|
|
||||||
|
|
||||||
cx, cy = center
|
|
||||||
|
|
||||||
# 如果有椭圆参数,绘制椭圆
|
|
||||||
if ellipse_params:
|
|
||||||
(ell_center, (width, height), angle) = ellipse_params
|
|
||||||
cx_ell, cy_ell = int(ell_center[0]), int(ell_center[1])
|
|
||||||
|
|
||||||
# 确定长轴和短轴
|
|
||||||
if width >= height:
|
|
||||||
# width 是长轴,height 是短轴
|
|
||||||
axes_major = width
|
|
||||||
axes_minor = height
|
|
||||||
major_angle = angle # 长轴角度就是 angle
|
|
||||||
minor_angle = angle + 90 # 短轴角度 = 长轴角度 + 90度
|
|
||||||
else:
|
|
||||||
# height 是长轴,width 是短轴
|
|
||||||
axes_major = height
|
|
||||||
axes_minor = width
|
|
||||||
major_angle = angle + 90 # 长轴角度 = width角度 + 90度
|
|
||||||
minor_angle = angle # 短轴角度就是 angle
|
|
||||||
|
|
||||||
# 使用 OpenCV 绘制椭圆(绿色,线宽2)
|
|
||||||
cv2.ellipse(img_cv,
|
|
||||||
(cx_ell, cy_ell), # 中心点
|
|
||||||
(int(width / 2), int(height / 2)), # 半宽、半高
|
|
||||||
angle, # 旋转角度(OpenCV需要原始angle)
|
|
||||||
0, 360, # 起始和结束角度
|
|
||||||
(0, 255, 0), # 绿色 (RGB格式)
|
|
||||||
2) # 线宽
|
|
||||||
|
|
||||||
# 绘制椭圆中心点(红色)
|
|
||||||
cv2.circle(img_cv, (cx_ell, cy_ell), 3, (255, 0, 0), -1)
|
|
||||||
|
|
||||||
import math
|
|
||||||
# 绘制短轴(蓝色线条)
|
|
||||||
minor_length = axes_minor / 2
|
|
||||||
minor_angle_rad = math.radians(minor_angle)
|
|
||||||
dx_minor = minor_length * math.cos(minor_angle_rad)
|
|
||||||
dy_minor = minor_length * math.sin(minor_angle_rad)
|
|
||||||
pt1_minor = (int(cx_ell - dx_minor), int(cy_ell - dy_minor))
|
|
||||||
pt2_minor = (int(cx_ell + dx_minor), int(cy_ell + dy_minor))
|
|
||||||
cv2.line(img_cv, pt1_minor, pt2_minor, (0, 0, 255), 2) # 蓝色 (RGB格式)
|
|
||||||
else:
|
|
||||||
# 如果没有椭圆参数,绘制圆形(红色)
|
|
||||||
cv2.circle(img_cv, (cx, cy), radius, (0, 0, 255), 2)
|
|
||||||
cv2.circle(img_cv, (cx, cy), 2, (0, 0, 255), -1)
|
|
||||||
|
|
||||||
# 转换回 maix image
|
|
||||||
result_img = image.cv2image(img_cv, False, False)
|
|
||||||
|
|
||||||
# 定义颜色对象用于文字
|
|
||||||
try:
|
|
||||||
color_black = image.Color.from_rgb(0, 0, 0)
|
|
||||||
except AttributeError:
|
|
||||||
color_black = image.Color(0, 0, 0)
|
|
||||||
|
|
||||||
# D. 添加文字信息
|
|
||||||
FOCAL_LENGTH_PIX = 1900
|
|
||||||
d = (REAL_RADIUS_CM * FOCAL_LENGTH_PIX) / radius1 / 100.0
|
|
||||||
info_str = f"R:{radius} M:{method} D:{d:.2f}"
|
|
||||||
print(info_str)
|
|
||||||
|
|
||||||
# 计算文字位置,防止超出图片边界
|
|
||||||
r_outer = int(radius * 11.0) if radius else 100
|
|
||||||
text_y = cy - r_outer - 20 if cy > r_outer + 20 else cy + r_outer + 20
|
|
||||||
|
|
||||||
# 调用 draw_string
|
|
||||||
result_img.draw_string(0, 0, info_str, color=color_black, scale=1.0)
|
|
||||||
|
|
||||||
# 5. 保存结果图片
|
|
||||||
base, ext = os.path.splitext(image_path)
|
|
||||||
output_path = f"{base}_result{ext}"
|
|
||||||
try:
|
|
||||||
result_img.save(output_path, quality=100)
|
|
||||||
print(f"[SUCCESS] 结果已保存至: {output_path}")
|
|
||||||
except Exception as e:
|
|
||||||
print(f"[ERROR] 保存图片失败: {e}")
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
# ================= 配置区域 =================
|
|
||||||
|
|
||||||
# 1. 设置要测试的图片路径
|
|
||||||
# 建议将图片放在与脚本同级目录,或者使用绝对路径
|
|
||||||
TARGET_IMAGE = "/root/phot/None_314_258_0_0041.bmp"
|
|
||||||
|
|
||||||
TARGET_DIR = "/root/phot" # 修改为你想要读取的目录路径
|
|
||||||
|
|
||||||
# 支持的图片格式
|
|
||||||
IMAGE_EXTENSIONS = ['.jpg', '.jpeg', '.png', '.bmp']
|
|
||||||
|
|
||||||
# ================= 执行区域 =================
|
|
||||||
if 'TARGET_DIR' in locals():
|
|
||||||
# 读取目录下所有图片文件,过滤掉 _result.jpg 后缀的文件
|
|
||||||
image_files = []
|
|
||||||
if os.path.exists(TARGET_DIR) and os.path.isdir(TARGET_DIR):
|
|
||||||
for filename in os.listdir(TARGET_DIR):
|
|
||||||
# 检查文件扩展名
|
|
||||||
if any(filename.lower().endswith(ext) for ext in IMAGE_EXTENSIONS):
|
|
||||||
# 过滤掉 _result.jpg 后缀的文件
|
|
||||||
if not filename.endswith('_result.jpg'):
|
|
||||||
filepath = os.path.join(TARGET_DIR, filename)
|
|
||||||
if os.path.isfile(filepath):
|
|
||||||
image_files.append(filepath)
|
|
||||||
|
|
||||||
# 按文件名排序(可选)
|
|
||||||
image_files.sort()
|
|
||||||
|
|
||||||
print(f"[INFO] 在目录 {TARGET_DIR} 中找到 {len(image_files)} 张图片")
|
|
||||||
|
|
||||||
# 处理每张图片
|
|
||||||
for img_path in image_files:
|
|
||||||
print(f"\n{'=' * 10} 开始处理: {img_path} {'=' * 10}")
|
|
||||||
run_offline_test(img_path)
|
|
||||||
else:
|
|
||||||
print(f"[ERROR] 目录不存在或不是有效目录: {TARGET_DIR}")
|
|
||||||
|
|
||||||
else:
|
|
||||||
run_offline_test(TARGET_IMAGE)
|
|
||||||
@@ -1,635 +0,0 @@
|
|||||||
#!/usr/bin/env python3
|
|
||||||
# -*- coding: utf-8 -*-
|
|
||||||
"""
|
|
||||||
离线测试脚本:直接复用 detect_circle 逻辑进行测试
|
|
||||||
运行环境:MaixPy (Sipeed MAIX)
|
|
||||||
"""
|
|
||||||
import sys
|
|
||||||
import os
|
|
||||||
# import time
|
|
||||||
from maix import image, time
|
|
||||||
import cv2
|
|
||||||
import numpy as np
|
|
||||||
|
|
||||||
# ==================== 全局配置 (与 test_main.py 保持一致) ====================
|
|
||||||
REAL_RADIUS_CM = 20 # 靶心实际半径(厘米)
|
|
||||||
|
|
||||||
|
|
||||||
# ==================== 复制的核心算法 ====================
|
|
||||||
# 注意:这里直接复制了 detect_circle 的逻辑,避免 import main 导致的冲突
|
|
||||||
|
|
||||||
|
|
||||||
def detect_circle_v3(frame, laser_point=None):
|
|
||||||
"""检测图像中的靶心(优先清晰轮廓,其次黄色区域)- 返回椭圆参数版本
|
|
||||||
增加红色圆圈检测,验证黄色圆圈是否为真正的靶心
|
|
||||||
如果提供 laser_point,会选择最接近激光点的目标
|
|
||||||
|
|
||||||
Args:
|
|
||||||
frame: 图像帧
|
|
||||||
laser_point: 激光点坐标 (x, y),用于多目标场景下的目标选择
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
(result_img, best_center, best_radius, method, best_radius1, ellipse_params)
|
|
||||||
"""
|
|
||||||
img_cv = image.image2cv(frame, False, False)
|
|
||||||
|
|
||||||
best_center = best_radius = best_radius1 = method = None
|
|
||||||
ellipse_params = None
|
|
||||||
|
|
||||||
# HSV 黄色掩码检测(模糊靶心)
|
|
||||||
hsv = cv2.cvtColor(img_cv, cv2.COLOR_RGB2HSV)
|
|
||||||
h, s, v = cv2.split(hsv)
|
|
||||||
|
|
||||||
# 调整饱和度策略:稍微增强,不要过度
|
|
||||||
s = np.clip(s * 1.1, 0, 255).astype(np.uint8)
|
|
||||||
|
|
||||||
hsv = cv2.merge((h, s, v))
|
|
||||||
|
|
||||||
# 放宽 HSV 阈值范围(针对模糊图像的关键调整)
|
|
||||||
lower_yellow = np.array([7, 80, 0]) # 饱和度下限降低,捕捉淡黄色
|
|
||||||
upper_yellow = np.array([32, 255, 255]) # 亮度上限拉满
|
|
||||||
|
|
||||||
mask_yellow = cv2.inRange(hsv, lower_yellow, upper_yellow)
|
|
||||||
|
|
||||||
# 调整形态学操作
|
|
||||||
kernel = cv2.getStructuringElement(cv2.MORPH_ELLIPSE, (5, 5))
|
|
||||||
mask_yellow = cv2.morphologyEx(mask_yellow, cv2.MORPH_CLOSE, kernel)
|
|
||||||
|
|
||||||
contours_yellow, _ = cv2.findContours(mask_yellow, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE)
|
|
||||||
|
|
||||||
# 存储所有有效的黄色-红色组合
|
|
||||||
valid_targets = []
|
|
||||||
|
|
||||||
if contours_yellow:
|
|
||||||
for cnt_yellow in contours_yellow:
|
|
||||||
area = cv2.contourArea(cnt_yellow)
|
|
||||||
perimeter = cv2.arcLength(cnt_yellow, True)
|
|
||||||
|
|
||||||
# 计算圆度
|
|
||||||
if perimeter > 0:
|
|
||||||
circularity = (4 * np.pi * area) / (perimeter * perimeter)
|
|
||||||
else:
|
|
||||||
circularity = 0
|
|
||||||
|
|
||||||
if area > 50 and circularity > 0.7:
|
|
||||||
print(f"[target] -> 面积:{area}, 圆度:{circularity:.2f}")
|
|
||||||
# 尝试拟合椭圆
|
|
||||||
yellow_center = None
|
|
||||||
yellow_radius = None
|
|
||||||
yellow_ellipse = None
|
|
||||||
|
|
||||||
if len(cnt_yellow) >= 5:
|
|
||||||
(x, y), (width, height), angle = cv2.fitEllipse(cnt_yellow)
|
|
||||||
yellow_ellipse = ((x, y), (width, height), angle)
|
|
||||||
axes_minor = min(width, height)
|
|
||||||
radius = axes_minor / 2
|
|
||||||
yellow_center = (int(x), int(y))
|
|
||||||
yellow_radius = int(radius)
|
|
||||||
else:
|
|
||||||
(x, y), radius = cv2.minEnclosingCircle(cnt_yellow)
|
|
||||||
yellow_center = (int(x), int(y))
|
|
||||||
yellow_radius = int(radius)
|
|
||||||
yellow_ellipse = None
|
|
||||||
|
|
||||||
# 如果检测到黄色圆圈,再检测红色圆圈进行验证
|
|
||||||
if yellow_center and yellow_radius:
|
|
||||||
# HSV 红色掩码检测(红色在HSV中跨越0度,需要两个范围)
|
|
||||||
# 红色范围1: 0-12度(接近0度的红色)
|
|
||||||
# 放宽S/V阈值:S>=30, V>=20 以捕获淡红/暗红
|
|
||||||
lower_red1 = np.array([0, 30, 20])
|
|
||||||
upper_red1 = np.array([12, 255, 255])
|
|
||||||
mask_red1 = cv2.inRange(hsv, lower_red1, upper_red1)
|
|
||||||
|
|
||||||
# 红色范围2: 168-180度(接近180度的红色)
|
|
||||||
lower_red2 = np.array([168, 30, 20])
|
|
||||||
upper_red2 = np.array([180, 255, 255])
|
|
||||||
mask_red2 = cv2.inRange(hsv, lower_red2, upper_red2)
|
|
||||||
|
|
||||||
# 合并两个红色掩码
|
|
||||||
mask_red = cv2.bitwise_or(mask_red1, mask_red2)
|
|
||||||
|
|
||||||
# 形态学操作:先CLOSE填充空洞,再DILATE加厚环状区域
|
|
||||||
kernel_red = cv2.getStructuringElement(cv2.MORPH_ELLIPSE, (5, 5))
|
|
||||||
mask_red = cv2.morphologyEx(mask_red, cv2.MORPH_CLOSE, kernel_red)
|
|
||||||
mask_red = cv2.dilate(mask_red, kernel_red, iterations=1)
|
|
||||||
|
|
||||||
contours_red, _ = cv2.findContours(mask_red, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE)
|
|
||||||
|
|
||||||
red_pixel_count = np.sum(mask_red > 0)
|
|
||||||
print(f"Debug -> 红色掩码: {red_pixel_count} 像素, {len(contours_red)} 个轮廓")
|
|
||||||
|
|
||||||
found_valid_red = False
|
|
||||||
|
|
||||||
if contours_red:
|
|
||||||
for cnt_red in contours_red:
|
|
||||||
area_red = cv2.contourArea(cnt_red)
|
|
||||||
perimeter_red = cv2.arcLength(cnt_red, True)
|
|
||||||
|
|
||||||
if perimeter_red > 0:
|
|
||||||
circularity_red = (4 * np.pi * area_red) / (perimeter_red * perimeter_red)
|
|
||||||
else:
|
|
||||||
circularity_red = 0
|
|
||||||
|
|
||||||
# 环状轮廓圆度可能偏低,放宽到0.2
|
|
||||||
print(f"Debug -> 红轮廓: 面积={area_red:.1f}, 圆度={circularity_red:.2f}" +
|
|
||||||
f" (面积>15={area_red > 15}, 圆度>0.2={circularity_red > 0.2})")
|
|
||||||
if area_red > 15 and circularity_red > 0.2:
|
|
||||||
if len(cnt_red) >= 5:
|
|
||||||
(x_red, y_red), (w_red, h_red), angle_red = cv2.fitEllipse(cnt_red)
|
|
||||||
radius_red = min(w_red, h_red) / 2
|
|
||||||
red_center = (int(x_red), int(y_red))
|
|
||||||
red_radius = int(radius_red)
|
|
||||||
else:
|
|
||||||
(x_red, y_red), radius_red = cv2.minEnclosingCircle(cnt_red)
|
|
||||||
red_center = (int(x_red), int(y_red))
|
|
||||||
red_radius = int(radius_red)
|
|
||||||
|
|
||||||
if red_center:
|
|
||||||
dx = yellow_center[0] - red_center[0]
|
|
||||||
dy = yellow_center[1] - red_center[1]
|
|
||||||
distance = np.sqrt(dx * dx + dy * dy)
|
|
||||||
|
|
||||||
max_distance = yellow_radius * 2.0
|
|
||||||
min_r = min(red_radius, yellow_radius)
|
|
||||||
max_r = max(red_radius, yellow_radius)
|
|
||||||
size_ratio = min_r / max_r if max_r > 0 else 0
|
|
||||||
print(f"Debug -> 圆心距={distance:.1f}(阈值={max_distance:.1f}), "
|
|
||||||
f"大小比={size_ratio:.2f}(阈值=0.5), "
|
|
||||||
f"距离OK={distance < max_distance}, 大小OK={size_ratio > 0.5}")
|
|
||||||
|
|
||||||
# 允许红圈在黄圈外侧或内侧,只要大小相近(较小/较大 >= 0.5)
|
|
||||||
if distance < max_distance and size_ratio > 0.5:
|
|
||||||
found_valid_red = True
|
|
||||||
print(
|
|
||||||
f"[target] -> 找到匹配的红圈: 黄心({yellow_center}), 红心({red_center}), 距离:{distance:.1f}, 黄半径:{yellow_radius}, 红半径:{red_radius}")
|
|
||||||
|
|
||||||
valid_targets.append({
|
|
||||||
'center': yellow_center,
|
|
||||||
'radius': yellow_radius,
|
|
||||||
'ellipse': yellow_ellipse,
|
|
||||||
'area': area
|
|
||||||
})
|
|
||||||
break
|
|
||||||
|
|
||||||
if not found_valid_red:
|
|
||||||
# 如果黄圈非常可靠(大且圆),在没有红圈验证时仍接受
|
|
||||||
if area > 30 and circularity > 0.85:
|
|
||||||
print(f"[target] -> 黄圈高置信度(面积:{area:.0f}, 圆度:{circularity:.2f}),跳过红圈验证直接接受")
|
|
||||||
valid_targets.append({
|
|
||||||
'center': yellow_center,
|
|
||||||
'radius': yellow_radius,
|
|
||||||
'ellipse': yellow_ellipse,
|
|
||||||
'area': area
|
|
||||||
})
|
|
||||||
else:
|
|
||||||
print("Debug -> 未找到匹配的红色圆圈,可能是误识别")
|
|
||||||
|
|
||||||
# 从所有有效目标中选择最佳目标
|
|
||||||
if valid_targets:
|
|
||||||
if laser_point:
|
|
||||||
# 如果有激光点,选择最接近激光点的目标
|
|
||||||
best_target = None
|
|
||||||
min_distance = float('inf')
|
|
||||||
for target in valid_targets:
|
|
||||||
dx = target['center'][0] - laser_point[0]
|
|
||||||
dy = target['center'][1] - laser_point[1]
|
|
||||||
distance = np.sqrt(dx * dx + dy * dy)
|
|
||||||
if distance < min_distance:
|
|
||||||
min_distance = distance
|
|
||||||
best_target = target
|
|
||||||
if best_target:
|
|
||||||
best_center = best_target['center']
|
|
||||||
best_radius = best_target['radius']
|
|
||||||
ellipse_params = best_target['ellipse']
|
|
||||||
method = "v3_ellipse_red_validated_laser_selected"
|
|
||||||
best_radius1 = best_radius * 5
|
|
||||||
else:
|
|
||||||
# 如果没有激光点,选择面积最大的目标
|
|
||||||
best_target = max(valid_targets, key=lambda t: t['area'])
|
|
||||||
best_center = best_target['center']
|
|
||||||
best_radius = best_target['radius']
|
|
||||||
ellipse_params = best_target['ellipse']
|
|
||||||
method = "v3_ellipse_red_validated"
|
|
||||||
best_radius1 = best_radius * 5
|
|
||||||
|
|
||||||
result_img = image.cv2image(img_cv, False, False)
|
|
||||||
return result_img, best_center, best_radius, method, best_radius1, ellipse_params
|
|
||||||
|
|
||||||
|
|
||||||
def detect_circle(frame):
|
|
||||||
"""检测图像中的靶心(优先清晰轮廓,其次黄色区域)"""
|
|
||||||
img_cv = image.image2cv(frame, False, False)
|
|
||||||
# gray = cv2.cvtColor(img_cv, cv2.COLOR_RGB2GRAY)
|
|
||||||
# blurred = cv2.GaussianBlur(gray, (5, 5), 0)
|
|
||||||
# edged = cv2.Canny(blurred, 50, 150)
|
|
||||||
# kernel = cv2.getStructuringElement(cv2.MORPH_ELLIPSE, (5, 5))
|
|
||||||
# ceroded = cv2.erode(cv2.dilate(edged, kernel), kernel)
|
|
||||||
|
|
||||||
# contours, _ = cv2.findContours(ceroded, cv2.RETR_TREE, cv2.CHAIN_APPROX_SIMPLE)
|
|
||||||
# best_center = best_radius = best_radius1 = method = None
|
|
||||||
|
|
||||||
# hsv = cv2.cvtColor(img_cv, cv2.COLOR_RGB2HSV)
|
|
||||||
# h, s, v = cv2.split(hsv)
|
|
||||||
# s = np.clip(s * 2, 0, 255).astype(np.uint8)
|
|
||||||
# hsv = cv2.merge((h, s, v))
|
|
||||||
# lower_yellow = np.array([7, 80, 0])
|
|
||||||
# upper_yellow = np.array([32, 255, 182])
|
|
||||||
# mask = cv2.inRange(hsv, lower_yellow, upper_yellow)
|
|
||||||
# kernel = cv2.getStructuringElement(cv2.MORPH_ELLIPSE, (5, 5))
|
|
||||||
# mask = cv2.morphologyEx(mask, cv2.MORPH_OPEN, kernel)
|
|
||||||
# mask = cv2.morphologyEx(mask, cv2.MORPH_DILATE, kernel)
|
|
||||||
# contours, _ = cv2.findContours(mask, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE)
|
|
||||||
# if contours:
|
|
||||||
# largest = max(contours, key=cv2.contourArea)
|
|
||||||
# if cv2.contourArea(largest) > 50:
|
|
||||||
# (x, y), radius = cv2.minEnclosingCircle(largest)
|
|
||||||
# best_center = (int(x), int(y))
|
|
||||||
# best_radius = int(radius)
|
|
||||||
# best_radius1 = radius * 5
|
|
||||||
# method = "v2"
|
|
||||||
|
|
||||||
# auto
|
|
||||||
# R:31 M:v2 D:2.410110127692767
|
|
||||||
# hsv = cv2.cvtColor(img_cv, cv2.COLOR_RGB2HSV)
|
|
||||||
# h, s, v = cv2.split(hsv)
|
|
||||||
|
|
||||||
# # 1. 增强饱和度(模糊照片需要更强的增强)
|
|
||||||
# s = np.clip(s * 2.5, 0, 255).astype(np.uint8) # 从2.0改为2.5
|
|
||||||
|
|
||||||
# # 2. 增强亮度(模糊照片可能偏暗)
|
|
||||||
# v = np.clip(v * 1.2, 0, 255).astype(np.uint8) # 新增:提升亮度
|
|
||||||
|
|
||||||
# hsv = cv2.merge((h, s, v))
|
|
||||||
|
|
||||||
# # 3. 放宽HSV颜色范围(特别是模糊照片)
|
|
||||||
# # 降低饱和度下限,提高亮度上限
|
|
||||||
# lower_yellow = np.array([5, 50, 30]) # H:5-35, S:50-255, V:30-255
|
|
||||||
# upper_yellow = np.array([35, 255, 255])
|
|
||||||
|
|
||||||
# mask = cv2.inRange(hsv, lower_yellow, upper_yellow)
|
|
||||||
|
|
||||||
# # 4. 增强形态学操作(连接被分割的区域)
|
|
||||||
# kernel_small = cv2.getStructuringElement(cv2.MORPH_ELLIPSE, (5, 5))
|
|
||||||
# kernel_large = cv2.getStructuringElement(cv2.MORPH_ELLIPSE, (9, 9)) # 更大的核
|
|
||||||
|
|
||||||
# # 先开运算去除噪声
|
|
||||||
# mask = cv2.morphologyEx(mask, cv2.MORPH_OPEN, kernel_small)
|
|
||||||
# # 多次膨胀连接区域(模糊照片需要更多膨胀)
|
|
||||||
# mask = cv2.dilate(mask, kernel_large, iterations=2) # 增加迭代次数
|
|
||||||
# mask = cv2.morphologyEx(mask, cv2.MORPH_CLOSE, kernel_large) # 闭运算填充空洞
|
|
||||||
|
|
||||||
# contours, _ = cv2.findContours(mask, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE)
|
|
||||||
# if contours:
|
|
||||||
# largest = max(contours, key=cv2.contourArea)
|
|
||||||
# area = cv2.contourArea(largest)
|
|
||||||
# if area > 50:
|
|
||||||
# # 5. 使用面积计算等效半径(更准确)
|
|
||||||
# equivalent_radius = np.sqrt(area / np.pi)
|
|
||||||
|
|
||||||
# # 6. 同时使用minEnclosingCircle作为备选(取较大值)
|
|
||||||
# (x, y), enclosing_radius = cv2.minEnclosingCircle(largest)
|
|
||||||
|
|
||||||
# # 取两者中的较大值,确保不遗漏
|
|
||||||
# radius = max(equivalent_radius, enclosing_radius)
|
|
||||||
|
|
||||||
# best_center = (int(x), int(y))
|
|
||||||
# best_radius = int(radius)
|
|
||||||
# best_radius1 = radius * 5
|
|
||||||
# method = "v2"
|
|
||||||
|
|
||||||
# codegee
|
|
||||||
# R:24 M:v2 D:3.061493895819174
|
|
||||||
# R:22 M:v2 D:3.3644971681267077 np.clip(s * 1.1, 0, 255)
|
|
||||||
hsv = cv2.cvtColor(img_cv, cv2.COLOR_RGB2HSV)
|
|
||||||
h, s, v = cv2.split(hsv)
|
|
||||||
|
|
||||||
# 2. 调整饱和度策略:
|
|
||||||
# 不要暴力翻倍,可以尝试稍微增强,或者使用 CLAHE 增强亮度/对比度
|
|
||||||
# 这里我们稍微增加一点饱和度,并确保不溢出
|
|
||||||
s = np.clip(s * 1.1, 0, 255).astype(np.uint8)
|
|
||||||
# 对亮度通道 v 也可以做一点 CLAHE 处理来增强对比度(可选)
|
|
||||||
# clahe = cv2.createCLAHE(clipLimit=2.0, tileGridSize=(8,8))
|
|
||||||
# v = clahe.apply(v)
|
|
||||||
|
|
||||||
hsv = cv2.merge((h, s, v))
|
|
||||||
|
|
||||||
# 3. 放宽 HSV 阈值范围(针对模糊图像的关键调整)
|
|
||||||
# 降低 S 的下限 (80 -> 35),提高 V 的上限 (182 -> 255)
|
|
||||||
lower_yellow = np.array([7, 80, 0]) # 饱和度下限降低,捕捉淡黄色
|
|
||||||
upper_yellow = np.array([32, 255, 255]) # 亮度上限拉满
|
|
||||||
|
|
||||||
mask = cv2.inRange(hsv, lower_yellow, upper_yellow)
|
|
||||||
|
|
||||||
# 4. 调整形态学操作
|
|
||||||
# 去掉 MORPH_OPEN,因为它会减小面积。
|
|
||||||
# 使用 MORPH_CLOSE (先膨胀后腐蚀) 来填充内部小黑洞,连接近邻区域
|
|
||||||
kernel = cv2.getStructuringElement(cv2.MORPH_ELLIPSE, (5, 5))
|
|
||||||
mask = cv2.morphologyEx(mask, cv2.MORPH_CLOSE, kernel)
|
|
||||||
# 再进行一次膨胀,确保边缘被包含进来
|
|
||||||
# mask = cv2.dilate(mask, kernel, iterations=1)
|
|
||||||
|
|
||||||
contours, _ = cv2.findContours(mask, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE)
|
|
||||||
|
|
||||||
if contours:
|
|
||||||
largest = max(contours, key=cv2.contourArea)
|
|
||||||
|
|
||||||
# 这里可以适当降低面积阈值,或者保持不变
|
|
||||||
if cv2.contourArea(largest) > 50:
|
|
||||||
# (x, y), radius = cv2.minEnclosingCircle(largest)
|
|
||||||
# best_center = (int(x), int(y))
|
|
||||||
# best_radius = int(radius)
|
|
||||||
|
|
||||||
# --- 核心修改开始 ---
|
|
||||||
# 1. 尝试拟合椭圆 (需要轮廓点至少为5个)
|
|
||||||
if len(largest) >= 5:
|
|
||||||
# 返回值: ((中心x, 中心y), (长轴, 短轴), 旋转角度)
|
|
||||||
(x, y), (axes_major, axes_minor), angle = cv2.fitEllipse(largest)
|
|
||||||
|
|
||||||
# 2. 计算半径
|
|
||||||
# 选项A:取长短轴的平均值 (比较稳健)
|
|
||||||
# radius = (axes_major + axes_minor) / 4
|
|
||||||
|
|
||||||
# 选项B:直接取短轴的一半 (抗模糊最强,推荐)
|
|
||||||
radius = axes_minor / 2
|
|
||||||
|
|
||||||
best_center = (int(x), int(y))
|
|
||||||
best_radius = int(radius)
|
|
||||||
method = "v2_ellipse"
|
|
||||||
else:
|
|
||||||
# 如果点太少无法拟合椭圆,降级回 minEnclosingCircle
|
|
||||||
(x, y), radius = cv2.minEnclosingCircle(largest)
|
|
||||||
best_center = (int(x), int(y))
|
|
||||||
best_radius = int(radius)
|
|
||||||
method = "v2"
|
|
||||||
# --- 核心修改结束 ---
|
|
||||||
|
|
||||||
# 你的后续逻辑
|
|
||||||
best_radius1 = radius * 5
|
|
||||||
|
|
||||||
# operas 4.5
|
|
||||||
# R:25 M:v2 D:2.9554872521538527
|
|
||||||
# hsv = cv2.cvtColor(img_cv, cv2.COLOR_RGB2HSV)
|
|
||||||
# h, s, v = cv2.split(hsv)
|
|
||||||
|
|
||||||
# # 1. 适度增强饱和度(不要过度,否则噪声也会增强)
|
|
||||||
# s = np.clip(s * 1.5, 0, 255).astype(np.uint8)
|
|
||||||
# hsv = cv2.merge((h, s, v))
|
|
||||||
|
|
||||||
# # 2. 放宽 HSV 阈值范围(关键改动)
|
|
||||||
# # - 饱和度下限从 80 降到 40(捕捉淡黄色)
|
|
||||||
# # - 亮度上限从 182 提高到 255(允许更亮的黄色)
|
|
||||||
# lower_yellow = np.array([7, 40, 30])
|
|
||||||
# upper_yellow = np.array([35, 255, 255])
|
|
||||||
|
|
||||||
# mask = cv2.inRange(hsv, lower_yellow, upper_yellow)
|
|
||||||
|
|
||||||
# # 3. 调整形态学操作:用 CLOSE 替代 OPEN
|
|
||||||
# # CLOSE(先膨胀后腐蚀):填充内部空洞,连接相邻区域
|
|
||||||
# # OPEN(先腐蚀后膨胀):会缩小区域,不适合模糊图像
|
|
||||||
# kernel = cv2.getStructuringElement(cv2.MORPH_ELLIPSE, (7, 7)) # 稍大的核
|
|
||||||
# mask = cv2.morphologyEx(mask, cv2.MORPH_CLOSE, kernel)
|
|
||||||
# mask = cv2.dilate(mask, kernel, iterations=1) # 额外膨胀,确保边缘被包含
|
|
||||||
|
|
||||||
# contours, _ = cv2.findContours(mask, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE)
|
|
||||||
# if contours:
|
|
||||||
# largest = max(contours, key=cv2.contourArea)
|
|
||||||
# if cv2.contourArea(largest) > 50:
|
|
||||||
# (x, y), radius = cv2.minEnclosingCircle(largest)
|
|
||||||
# best_center = (int(x), int(y))
|
|
||||||
# best_radius = int(radius)
|
|
||||||
# best_radius1 = radius * 5
|
|
||||||
# method = "v2"
|
|
||||||
|
|
||||||
# # --- 新增:将 Mask 叠加到原图上用于调试 ---
|
|
||||||
# # 创建一个彩色掩码(红色通道为255,其他为0)
|
|
||||||
# mask_overlay = np.zeros_like(img_cv)
|
|
||||||
# mask_overlay[:, :, 2] = mask # 将掩码放在红色通道 (BGR中的R)
|
|
||||||
#
|
|
||||||
# cv2.addWeighted(img_cv, 0.6, mask_overlay, 0.4, 0, img_cv)
|
|
||||||
|
|
||||||
result_img = image.cv2image(img_cv, False, False)
|
|
||||||
return result_img, best_center, best_radius, method, best_radius1
|
|
||||||
|
|
||||||
|
|
||||||
def detect_circle_v2(frame):
|
|
||||||
"""检测图像中的靶心(优先清晰轮廓,其次黄色区域)- 返回椭圆参数版本"""
|
|
||||||
global REAL_RADIUS_CM
|
|
||||||
img_cv = image.image2cv(frame, False, False)
|
|
||||||
|
|
||||||
best_center = best_radius = best_radius1 = method = None
|
|
||||||
ellipse_params = None # 存储椭圆参数 ((x, y), (axes_major, axes_minor), angle)
|
|
||||||
|
|
||||||
# HSV 黄色掩码检测(模糊靶心)
|
|
||||||
hsv = cv2.cvtColor(img_cv, cv2.COLOR_RGB2HSV)
|
|
||||||
h, s, v = cv2.split(hsv)
|
|
||||||
|
|
||||||
# 调整饱和度策略:稍微增强,不要过度
|
|
||||||
s = np.clip(s * 1.1, 0, 255).astype(np.uint8)
|
|
||||||
|
|
||||||
hsv = cv2.merge((h, s, v))
|
|
||||||
|
|
||||||
# 放宽 HSV 阈值范围(针对模糊图像的关键调整)
|
|
||||||
lower_yellow = np.array([7, 80, 0]) # 饱和度下限降低,捕捉淡黄色
|
|
||||||
upper_yellow = np.array([32, 255, 255]) # 亮度上限拉满
|
|
||||||
|
|
||||||
mask = cv2.inRange(hsv, lower_yellow, upper_yellow)
|
|
||||||
|
|
||||||
# 调整形态学操作
|
|
||||||
# 使用 MORPH_CLOSE (先膨胀后腐蚀) 来填充内部小黑洞,连接近邻区域
|
|
||||||
kernel = cv2.getStructuringElement(cv2.MORPH_ELLIPSE, (5, 5))
|
|
||||||
mask = cv2.morphologyEx(mask, cv2.MORPH_CLOSE, kernel)
|
|
||||||
|
|
||||||
contours, _ = cv2.findContours(mask, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE)
|
|
||||||
|
|
||||||
if contours:
|
|
||||||
largest = max(contours, key=cv2.contourArea)
|
|
||||||
|
|
||||||
if cv2.contourArea(largest) > 50:
|
|
||||||
# 尝试拟合椭圆 (需要轮廓点至少为5个)
|
|
||||||
if len(largest) >= 5:
|
|
||||||
# 返回值: ((中心x, 中心y), (width, height), 旋转角度)
|
|
||||||
# 注意:width 和 height 是外接矩形的尺寸,不是长轴和短轴
|
|
||||||
(x, y), (width, height), angle = cv2.fitEllipse(largest)
|
|
||||||
|
|
||||||
# 保存椭圆参数(保持原始顺序,用于绘制)
|
|
||||||
ellipse_params = ((x, y), (width, height), angle)
|
|
||||||
|
|
||||||
# 计算半径:使用较小的尺寸作为短轴
|
|
||||||
axes_minor = min(width, height)
|
|
||||||
radius = axes_minor / 2
|
|
||||||
|
|
||||||
best_center = (int(x), int(y))
|
|
||||||
best_radius = int(radius)
|
|
||||||
method = "v2_ellipse"
|
|
||||||
else:
|
|
||||||
# 如果点太少无法拟合椭圆,降级回 minEnclosingCircle
|
|
||||||
(x, y), radius = cv2.minEnclosingCircle(largest)
|
|
||||||
best_center = (int(x), int(y))
|
|
||||||
best_radius = int(radius)
|
|
||||||
method = "v2"
|
|
||||||
ellipse_params = None # 圆形,没有椭圆参数
|
|
||||||
|
|
||||||
best_radius1 = radius * 5
|
|
||||||
|
|
||||||
result_img = image.cv2image(img_cv, False, False)
|
|
||||||
return result_img, best_center, best_radius, method, best_radius1, ellipse_params
|
|
||||||
|
|
||||||
|
|
||||||
# ==================== 测试逻辑 ====================
|
|
||||||
|
|
||||||
def run_offline_test(image_path):
|
|
||||||
"""读取图片,检测圆,绘制结果,保存图片"""
|
|
||||||
|
|
||||||
# 1. 检查文件是否存在
|
|
||||||
if not os.path.exists(image_path):
|
|
||||||
print(f"[ERROR] 找不到图片文件: {image_path}")
|
|
||||||
return
|
|
||||||
|
|
||||||
# 2. 使用 maix.image 读取图片 (适配 MaixPy v4)
|
|
||||||
try:
|
|
||||||
# 使用 image.load 读取文件,返回 Image 对象
|
|
||||||
img = image.load(image_path)
|
|
||||||
print(f"[INFO] 成功读取图片: {image_path} (尺寸: {img.width()}x{img.height()})")
|
|
||||||
except Exception as e:
|
|
||||||
print(f"[ERROR] 读取图片失败: {e}")
|
|
||||||
print("提示:请确认 MaixPy 版本是否为 v4,且图片路径正确。")
|
|
||||||
return
|
|
||||||
|
|
||||||
# 3. 调用 detect_circle_v2 函数
|
|
||||||
print("[INFO] 正在调用 detect_circle_v2 进行检测...")
|
|
||||||
start_time = time.ticks_ms()
|
|
||||||
|
|
||||||
result_img, center, radius, method, radius1, ellipse_params = detect_circle_v3(img)
|
|
||||||
|
|
||||||
cost_time = time.ticks_ms() - start_time
|
|
||||||
print(f"[INFO] 检测完成,耗时: {cost_time}ms")
|
|
||||||
print(f" 结果 -> 圆心: {center}, 半径: {radius}, 方法: {method}")
|
|
||||||
if ellipse_params:
|
|
||||||
(ell_center, (width, height), angle) = ellipse_params
|
|
||||||
print(
|
|
||||||
f" 椭圆 -> 中心: ({ell_center[0]:.1f}, {ell_center[1]:.1f}), 长轴: {max(width, height):.1f}, 短轴: {min(width, height):.1f}, 角度: {angle:.1f}°")
|
|
||||||
|
|
||||||
# 4. 绘制辅助线(可选,用于调试)
|
|
||||||
if center and radius:
|
|
||||||
# 为了绘制椭圆,需要转换回 cv2 图像
|
|
||||||
img_cv = image.image2cv(result_img, False, False)
|
|
||||||
|
|
||||||
cx, cy = center
|
|
||||||
|
|
||||||
# 如果有椭圆参数,绘制椭圆
|
|
||||||
if ellipse_params:
|
|
||||||
(ell_center, (width, height), angle) = ellipse_params
|
|
||||||
cx_ell, cy_ell = int(ell_center[0]), int(ell_center[1])
|
|
||||||
|
|
||||||
# 确定长轴和短轴
|
|
||||||
if width >= height:
|
|
||||||
# width 是长轴,height 是短轴
|
|
||||||
axes_major = width
|
|
||||||
axes_minor = height
|
|
||||||
major_angle = angle # 长轴角度就是 angle
|
|
||||||
minor_angle = angle + 90 # 短轴角度 = 长轴角度 + 90度
|
|
||||||
else:
|
|
||||||
# height 是长轴,width 是短轴
|
|
||||||
axes_major = height
|
|
||||||
axes_minor = width
|
|
||||||
major_angle = angle + 90 # 长轴角度 = width角度 + 90度
|
|
||||||
minor_angle = angle # 短轴角度就是 angle
|
|
||||||
|
|
||||||
# 使用 OpenCV 绘制椭圆(绿色,线宽2)
|
|
||||||
cv2.ellipse(img_cv,
|
|
||||||
(cx_ell, cy_ell), # 中心点
|
|
||||||
(int(width / 2), int(height / 2)), # 半宽、半高
|
|
||||||
angle, # 旋转角度(OpenCV需要原始angle)
|
|
||||||
0, 360, # 起始和结束角度
|
|
||||||
(0, 255, 0), # 绿色 (RGB格式)
|
|
||||||
2) # 线宽
|
|
||||||
|
|
||||||
# 绘制椭圆中心点(红色)
|
|
||||||
cv2.circle(img_cv, (cx_ell, cy_ell), 3, (255, 0, 0), -1)
|
|
||||||
|
|
||||||
import math
|
|
||||||
# 绘制短轴(蓝色线条)
|
|
||||||
minor_length = axes_minor / 2
|
|
||||||
minor_angle_rad = math.radians(minor_angle)
|
|
||||||
dx_minor = minor_length * math.cos(minor_angle_rad)
|
|
||||||
dy_minor = minor_length * math.sin(minor_angle_rad)
|
|
||||||
pt1_minor = (int(cx_ell - dx_minor), int(cy_ell - dy_minor))
|
|
||||||
pt2_minor = (int(cx_ell + dx_minor), int(cy_ell + dy_minor))
|
|
||||||
cv2.line(img_cv, pt1_minor, pt2_minor, (0, 0, 255), 2) # 蓝色 (RGB格式)
|
|
||||||
else:
|
|
||||||
# 如果没有椭圆参数,绘制圆形(红色)
|
|
||||||
cv2.circle(img_cv, (cx, cy), radius, (0, 0, 255), 2)
|
|
||||||
cv2.circle(img_cv, (cx, cy), 2, (0, 0, 255), -1)
|
|
||||||
|
|
||||||
# 转换回 maix image
|
|
||||||
result_img = image.cv2image(img_cv, False, False)
|
|
||||||
|
|
||||||
# 定义颜色对象用于文字
|
|
||||||
try:
|
|
||||||
color_black = image.Color.from_rgb(0, 0, 0)
|
|
||||||
except AttributeError:
|
|
||||||
color_black = image.Color(0, 0, 0)
|
|
||||||
|
|
||||||
# D. 添加文字信息
|
|
||||||
FOCAL_LENGTH_PIX = 1900
|
|
||||||
d = (REAL_RADIUS_CM * FOCAL_LENGTH_PIX) / radius1 / 100.0
|
|
||||||
info_str = f"R:{radius} M:{method} D:{d:.2f}"
|
|
||||||
print(info_str)
|
|
||||||
|
|
||||||
# 计算文字位置,防止超出图片边界
|
|
||||||
r_outer = int(radius * 11.0) if radius else 100
|
|
||||||
text_y = cy - r_outer - 20 if cy > r_outer + 20 else cy + r_outer + 20
|
|
||||||
|
|
||||||
# 调用 draw_string
|
|
||||||
result_img.draw_string(0, 0, info_str, color=color_black, scale=1.0)
|
|
||||||
|
|
||||||
# 5. 保存结果图片
|
|
||||||
output_path = image_path.replace(".bmp", "_result.bmp")
|
|
||||||
output_path = image_path.replace(".jpg", "_result.jpg")
|
|
||||||
try:
|
|
||||||
result_img.save(output_path, quality=100)
|
|
||||||
print(f"[SUCCESS] 结果已保存至: {output_path}")
|
|
||||||
except Exception as e:
|
|
||||||
print(f"[ERROR] 保存图片失败: {e}")
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
# ================= 配置区域 =================
|
|
||||||
|
|
||||||
# 1. 设置要测试的图片路径
|
|
||||||
# 建议将图片放在与脚本同级目录,或者使用绝对路径
|
|
||||||
TARGET_IMAGE = "/root/phot/None_314_258_0_0041.bmp"
|
|
||||||
|
|
||||||
TARGET_DIR = "/root/phot" # 修改为你想要读取的目录路径
|
|
||||||
|
|
||||||
# 支持的图片格式
|
|
||||||
IMAGE_EXTENSIONS = ['.jpg', '.jpeg', '.png', '.bmp']
|
|
||||||
|
|
||||||
# ================= 执行区域 =================
|
|
||||||
if 'TARGET_DIR' in locals():
|
|
||||||
# 读取目录下所有图片文件,过滤掉 _result.jpg 后缀的文件
|
|
||||||
image_files = []
|
|
||||||
if os.path.exists(TARGET_DIR) and os.path.isdir(TARGET_DIR):
|
|
||||||
for filename in os.listdir(TARGET_DIR):
|
|
||||||
# 检查文件扩展名
|
|
||||||
if any(filename.lower().endswith(ext) for ext in IMAGE_EXTENSIONS):
|
|
||||||
# 过滤掉 _result.jpg 后缀的文件
|
|
||||||
if filename.endswith('no_target.jpg'):
|
|
||||||
filepath = os.path.join(TARGET_DIR, filename)
|
|
||||||
if os.path.isfile(filepath):
|
|
||||||
image_files.append(filepath)
|
|
||||||
|
|
||||||
# 按文件名排序(可选)
|
|
||||||
image_files.sort()
|
|
||||||
|
|
||||||
print(f"[INFO] 在目录 {TARGET_DIR} 中找到 {len(image_files)} 张图片")
|
|
||||||
|
|
||||||
# 处理每张图片
|
|
||||||
for img_path in image_files:
|
|
||||||
print(f"\n{'=' * 10} 开始处理: {img_path} {'=' * 10}")
|
|
||||||
run_offline_test(img_path)
|
|
||||||
else:
|
|
||||||
print(f"[ERROR] 目录不存在或不是有效目录: {TARGET_DIR}")
|
|
||||||
|
|
||||||
else:
|
|
||||||
run_offline_test(TARGET_IMAGE)
|
|
||||||
541
test/test_laser_center_point.py
Normal file
541
test/test_laser_center_point.py
Normal file
@@ -0,0 +1,541 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
"""
|
||||||
|
激光中心点检测单元测试(单文件,无项目依赖)
|
||||||
|
直接使用 maix 标准库,实现红色激光点坐标检测
|
||||||
|
|
||||||
|
运行方式:
|
||||||
|
python3 test/test_laser_center_point.py
|
||||||
|
|
||||||
|
Ctrl+C 退出,按 s 保存截图
|
||||||
|
"""
|
||||||
|
|
||||||
|
from maix import camera, display, image, time, app, uart, pinmap
|
||||||
|
import os
|
||||||
|
import struct
|
||||||
|
import select
|
||||||
|
|
||||||
|
_USE_CV = False
|
||||||
|
try:
|
||||||
|
import cv2
|
||||||
|
import numpy as np
|
||||||
|
_USE_CV = True
|
||||||
|
except ImportError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
WIDTH = 640
|
||||||
|
HEIGHT = 480
|
||||||
|
THRESHOLD = 140
|
||||||
|
SEARCH_RADIUS = 50
|
||||||
|
|
||||||
|
|
||||||
|
def read_key_ev():
|
||||||
|
"""非阻塞读取 /dev/input/event0 按键(返回 key_code 或 -1)"""
|
||||||
|
try:
|
||||||
|
r, _, _ = select.select([_key_fd], [], [], 0)
|
||||||
|
if r:
|
||||||
|
event = _key_fd.read(16)
|
||||||
|
if len(event) == 16:
|
||||||
|
_, _, etype, code, value = struct.unpack("IIHHI", event)
|
||||||
|
if etype == 1 and value == 1:
|
||||||
|
return code
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
return -1
|
||||||
|
|
||||||
|
|
||||||
|
def find_ellipse(img_cv, cx, cy, roi_r, th):
|
||||||
|
x1 = max(0, cx - roi_r)
|
||||||
|
x2 = min(WIDTH, cx + roi_r)
|
||||||
|
y1 = max(0, cy - roi_r)
|
||||||
|
y2 = min(HEIGHT, cy + roi_r)
|
||||||
|
roi = img_cv[y1:y2, x1:x2]
|
||||||
|
if roi.size == 0:
|
||||||
|
return None
|
||||||
|
r = roi[:, :, 0].astype(np.int32)
|
||||||
|
g = roi[:, :, 1].astype(np.int32)
|
||||||
|
b = roi[:, :, 2].astype(np.int32)
|
||||||
|
mask = (r > th) & (r > g * 1.5) & (r > b * 1.5)
|
||||||
|
oe = (r > 200) & (g > 200) & (b > 200) & (r >= g) & (r >= b) & ((r - g) > 10) & ((r - b) > 10)
|
||||||
|
combined = (mask | oe).astype(np.uint8) * 255
|
||||||
|
contours, _ = cv2.findContours(combined, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE)
|
||||||
|
if not contours:
|
||||||
|
return None
|
||||||
|
largest = max(contours, key=cv2.contourArea)
|
||||||
|
if cv2.contourArea(largest) < 5:
|
||||||
|
return None
|
||||||
|
cnt = largest.copy()
|
||||||
|
for pt in cnt:
|
||||||
|
pt[0][0] += x1
|
||||||
|
pt[0][1] += y1
|
||||||
|
if len(cnt) >= 5:
|
||||||
|
(ex, ey), (ew, eh), ang = cv2.fitEllipse(cnt)
|
||||||
|
mask_ellipse = np.zeros((HEIGHT, WIDTH), dtype=np.uint8)
|
||||||
|
cv2.ellipse(mask_ellipse, (int(ex), int(ey)), (int(ew / 2), int(eh / 2)), ang, 0, 360, 255, -1)
|
||||||
|
brightness = img_cv[:, :, 0].astype(np.int32) + img_cv[:, :, 1].astype(np.int32) + img_cv[:, :, 2].astype(np.int32)
|
||||||
|
masked = np.where(mask_ellipse > 0, brightness, 0)
|
||||||
|
vals = masked[masked > 0]
|
||||||
|
if len(vals) > 0:
|
||||||
|
bth = np.percentile(vals, 90)
|
||||||
|
bmask = (masked >= bth).astype(np.uint8) * 255
|
||||||
|
bcontours, _ = cv2.findContours(bmask, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE)
|
||||||
|
if bcontours:
|
||||||
|
blargest = max(bcontours, key=cv2.contourArea)
|
||||||
|
if cv2.contourArea(blargest) >= 3 and len(blargest) >= 5:
|
||||||
|
(ix, iy), _, _ = cv2.fitEllipse(blargest)
|
||||||
|
return (float(ix), float(iy))
|
||||||
|
M = cv2.moments(blargest)
|
||||||
|
if M["m00"] > 0:
|
||||||
|
return (float(M["m10"] / M["m00"]), float(M["m01"] / M["m00"]))
|
||||||
|
return (float(ex), float(ey))
|
||||||
|
M = cv2.moments(cnt)
|
||||||
|
if M["m00"] > 0:
|
||||||
|
return (float(M["m10"] / M["m00"]), float(M["m01"] / M["m00"]))
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def find_brightest(img_cv, cx, cy, roi_r, th):
|
||||||
|
x1 = max(0, cx - roi_r)
|
||||||
|
x2 = min(WIDTH, cx + roi_r)
|
||||||
|
y1 = max(0, cy - roi_r)
|
||||||
|
y2 = min(HEIGHT, cy + roi_r)
|
||||||
|
best_score = 0
|
||||||
|
best_pos = None
|
||||||
|
for y in range(y1, y2):
|
||||||
|
for x in range(x1, x2):
|
||||||
|
r, g, b = int(img_cv[y, x, 0]), int(img_cv[y, x, 1]), int(img_cv[y, x, 2])
|
||||||
|
is_red = (r > th and r > g * 1.5 and r > b * 1.5)
|
||||||
|
is_oe = (r > 200 and g > 200 and b > 200 and r >= g and r >= b and (r - g) > 10 and (r - b) > 10)
|
||||||
|
if is_red or is_oe:
|
||||||
|
score = r + g + b
|
||||||
|
dx, dy = x - cx, y - cy
|
||||||
|
dist = (dx * dx + dy * dy) ** 0.5
|
||||||
|
score *= max(0.5, 1.0 - (dist / roi_r) * 0.5)
|
||||||
|
if score > best_score:
|
||||||
|
best_score = score
|
||||||
|
best_pos = (float(x), float(y))
|
||||||
|
return best_pos
|
||||||
|
|
||||||
|
|
||||||
|
# 打开键盘输入设备
|
||||||
|
_key_fd = None
|
||||||
|
try:
|
||||||
|
_key_fd = open("/dev/input/event0", "rb")
|
||||||
|
except Exception:
|
||||||
|
try:
|
||||||
|
_key_fd = open("/dev/input/event1", "rb")
|
||||||
|
except Exception:
|
||||||
|
_key_fd = None
|
||||||
|
|
||||||
|
print("=" * 50)
|
||||||
|
print("激光中心点检测单元测试")
|
||||||
|
print("=" * 50)
|
||||||
|
print()
|
||||||
|
|
||||||
|
cam = camera.Camera(WIDTH, HEIGHT)
|
||||||
|
disp = display.Display()
|
||||||
|
print("[OK] 摄像头和显示初始化完成")
|
||||||
|
|
||||||
|
# 初始化激光串口
|
||||||
|
_laser_on = False
|
||||||
|
_laser_uart = None
|
||||||
|
try:
|
||||||
|
pinmap.set_pin_function("A18", "UART1_RX")
|
||||||
|
pinmap.set_pin_function("A19", "UART1_TX")
|
||||||
|
_laser_uart = uart.UART("/dev/ttyS1", 9600)
|
||||||
|
_laser_uart.read(-1)
|
||||||
|
print("[OK] 激光串口初始化完成")
|
||||||
|
except Exception as e:
|
||||||
|
print(f"[WARN] 激光串口初始化失败: {e}")
|
||||||
|
|
||||||
|
LASER_ON = bytes([0xAA, 0x00, 0x01, 0xBE, 0x00, 0x01, 0x00, 0x01, 0xC1])
|
||||||
|
LASER_OFF = bytes([0xAA, 0x00, 0x01, 0xBE, 0x00, 0x01, 0x00, 0x00, 0xC0])
|
||||||
|
|
||||||
|
# 默认开启激光
|
||||||
|
if _laser_uart:
|
||||||
|
try:
|
||||||
|
_laser_uart.write(LASER_ON)
|
||||||
|
time.sleep_ms(50)
|
||||||
|
_laser_uart.read(-1)
|
||||||
|
_laser_on = True
|
||||||
|
print("[OK] 激光已开启")
|
||||||
|
except Exception as e:
|
||||||
|
print(f"[WARN] 开启激光失败: {e}")
|
||||||
|
print()
|
||||||
|
|
||||||
|
pos_ellipse = None
|
||||||
|
pos_bright = None
|
||||||
|
frame_count = 0
|
||||||
|
use_ellipse = True
|
||||||
|
|
||||||
|
while not app.need_exit():
|
||||||
|
frame = cam.read()
|
||||||
|
if frame is None:
|
||||||
|
time.sleep_ms(10)
|
||||||
|
continue
|
||||||
|
|
||||||
|
frame_count += 1
|
||||||
|
|
||||||
|
if _USE_CV:
|
||||||
|
img_cv = image.image2cv(frame, False, False)
|
||||||
|
cx, cy = WIDTH // 2, HEIGHT // 2
|
||||||
|
|
||||||
|
t0 = time.ticks_ms()
|
||||||
|
pos_ellipse = find_ellipse(img_cv, cx, cy, SEARCH_RADIUS, THRESHOLD)
|
||||||
|
t1 = time.ticks_ms()
|
||||||
|
pos_bright = find_brightest(img_cv, cx, cy, SEARCH_RADIUS, THRESHOLD)
|
||||||
|
t2 = time.ticks_ms()
|
||||||
|
|
||||||
|
dt_e = abs(time.ticks_diff(t0, t1))
|
||||||
|
dt_b = abs(time.ticks_diff(t1, t2))
|
||||||
|
|
||||||
|
if frame_count % 5 == 0:
|
||||||
|
e_str = f"({pos_ellipse[0]:.1f},{pos_ellipse[1]:.1f})" if pos_ellipse else "None"
|
||||||
|
b_str = f"({pos_bright[0]:.1f},{pos_bright[1]:.1f})" if pos_bright else "None"
|
||||||
|
print(f"[LASER] ellipse={e_str} ({dt_e}ms) brightest={b_str} ({dt_b}ms) "
|
||||||
|
f"th={THRESHOLD} radius={SEARCH_RADIUS}")
|
||||||
|
|
||||||
|
# 叠加显示
|
||||||
|
pos = pos_ellipse if use_ellipse else pos_bright
|
||||||
|
h, w = img_cv.shape[:2]
|
||||||
|
cv2.circle(img_cv, (cx, cy), SEARCH_RADIUS, (0, 255, 0), 1)
|
||||||
|
cv2.circle(img_cv, (cx, cy), 2, (0, 255, 0), -1)
|
||||||
|
if pos:
|
||||||
|
x, y = int(pos[0]), int(pos[1])
|
||||||
|
cv2.circle(img_cv, (x, y), 6, (0, 0, 255), 2)
|
||||||
|
cv2.line(img_cv, (x - 14, y), (x + 14, y), (0, 0, 255), 1)
|
||||||
|
cv2.line(img_cv, (x, y - 14), (x, y + 14), (0, 0, 255), 1)
|
||||||
|
cv2.putText(img_cv, f"({x},{y})", (x + 10, y - 10),
|
||||||
|
cv2.FONT_HERSHEY_SIMPLEX, 0.5, (0, 0, 255), 1, cv2.LINE_AA)
|
||||||
|
info = [
|
||||||
|
f"pos={pos if pos else 'None'}",
|
||||||
|
f"method={'ellipse' if use_ellipse else 'brightest'} th={THRESHOLD}",
|
||||||
|
f"laser={'ON' if _laser_on else 'OFF'}",
|
||||||
|
]
|
||||||
|
for i, line in enumerate(info):
|
||||||
|
cv2.putText(img_cv, line, (8, 20 + i * 22),
|
||||||
|
cv2.FONT_HERSHEY_SIMPLEX, 0.5, (0, 255, 0), 1, cv2.LINE_AA)
|
||||||
|
|
||||||
|
display_frame = image.cv2image(img_cv, False, False)
|
||||||
|
else:
|
||||||
|
display_frame = frame
|
||||||
|
|
||||||
|
disp.show(display_frame)
|
||||||
|
|
||||||
|
# 按键处理(非阻塞)
|
||||||
|
key = read_key_ev()
|
||||||
|
if key > 0:
|
||||||
|
c = chr(key & 0xFF) if key < 256 else ""
|
||||||
|
if key == 113 or key == 81 or key == 0x1b: # q/Q/ESC
|
||||||
|
break
|
||||||
|
if c == "e" or key == 18: # e
|
||||||
|
use_ellipse = not use_ellipse
|
||||||
|
print(f"[KEY] Method: {'ellipse' if use_ellipse else 'brightest'}")
|
||||||
|
if c == "l" or key == 12: # l
|
||||||
|
_laser_on = not _laser_on
|
||||||
|
if _laser_uart:
|
||||||
|
try:
|
||||||
|
_laser_uart.write(LASER_ON if _laser_on else LASER_OFF)
|
||||||
|
time.sleep_ms(30)
|
||||||
|
_laser_uart.read(-1)
|
||||||
|
print(f"[KEY] Laser: {'ON' if _laser_on else 'OFF'}")
|
||||||
|
except Exception as e:
|
||||||
|
print(f"[KEY] Laser error: {e}")
|
||||||
|
else:
|
||||||
|
print("[KEY] Laser UART not available")
|
||||||
|
time.sleep_ms(30)
|
||||||
|
|
||||||
|
# 关闭激光
|
||||||
|
if _laser_on and _laser_uart:
|
||||||
|
try:
|
||||||
|
_laser_uart.write(LASER_OFF)
|
||||||
|
_laser_uart.read(-1)
|
||||||
|
print("[EXIT] 激光已关闭")
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
print("[EXIT] 测试结束")
|
||||||
|
if _key_fd:
|
||||||
|
_key_fd.close()
|
||||||
|
#!/usr/bin/env python3
|
||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
"""
|
||||||
|
激光中心点检测单元测试(单文件,无项目依赖)
|
||||||
|
直接使用 maix 标准库,实现红色激光点坐标检测
|
||||||
|
|
||||||
|
运行方式:
|
||||||
|
python3 test/test_laser_center_point.py
|
||||||
|
|
||||||
|
Ctrl+C 退出,按 s 保存截图
|
||||||
|
"""
|
||||||
|
|
||||||
|
from maix import camera, display, image, time, app, uart, pinmap
|
||||||
|
import os
|
||||||
|
import struct
|
||||||
|
import select
|
||||||
|
|
||||||
|
_USE_CV = False
|
||||||
|
try:
|
||||||
|
import cv2
|
||||||
|
import numpy as np
|
||||||
|
_USE_CV = True
|
||||||
|
except ImportError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
WIDTH = 640
|
||||||
|
HEIGHT = 480
|
||||||
|
THRESHOLD = 120
|
||||||
|
RED_RATIO = 1.3
|
||||||
|
SEARCH_RADIUS = 60
|
||||||
|
|
||||||
|
|
||||||
|
def read_key_ev():
|
||||||
|
"""非阻塞读取 /dev/input/event0 按键(返回 key_code 或 -1)"""
|
||||||
|
try:
|
||||||
|
r, _, _ = select.select([_key_fd], [], [], 0)
|
||||||
|
if r:
|
||||||
|
event = _key_fd.read(16)
|
||||||
|
if len(event) == 16:
|
||||||
|
_, _, etype, code, value = struct.unpack("IIHHI", event)
|
||||||
|
if etype == 1 and value == 1:
|
||||||
|
return code
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
return -1
|
||||||
|
|
||||||
|
|
||||||
|
def find_ellipse(img_cv, cx, cy, roi_r, th, ratio):
|
||||||
|
x1 = max(0, cx - roi_r)
|
||||||
|
x2 = min(WIDTH, cx + roi_r)
|
||||||
|
y1 = max(0, cy - roi_r)
|
||||||
|
y2 = min(HEIGHT, cy + roi_r)
|
||||||
|
roi = img_cv[y1:y2, x1:x2]
|
||||||
|
if roi.size == 0:
|
||||||
|
return None
|
||||||
|
r = roi[:, :, 0].astype(np.int32)
|
||||||
|
g = roi[:, :, 1].astype(np.int32)
|
||||||
|
b = roi[:, :, 2].astype(np.int32)
|
||||||
|
mask = (r > th) & (r > g * ratio) & (r > b * ratio)
|
||||||
|
oe = (r > 200) & (g > 200) & (b > 200) & (r >= g) & (r >= b) & ((r - g) > 10) & ((r - b) > 10)
|
||||||
|
combined = (mask | oe).astype(np.uint8) * 255
|
||||||
|
contours, _ = cv2.findContours(combined, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE)
|
||||||
|
if not contours:
|
||||||
|
return None
|
||||||
|
largest = max(contours, key=cv2.contourArea)
|
||||||
|
if cv2.contourArea(largest) < 5:
|
||||||
|
return None
|
||||||
|
cnt = largest.copy()
|
||||||
|
for pt in cnt:
|
||||||
|
pt[0][0] += x1
|
||||||
|
pt[0][1] += y1
|
||||||
|
if len(cnt) >= 5:
|
||||||
|
(ex, ey), (ew, eh), ang = cv2.fitEllipse(cnt)
|
||||||
|
mask_ellipse = np.zeros((HEIGHT, WIDTH), dtype=np.uint8)
|
||||||
|
cv2.ellipse(mask_ellipse, (int(ex), int(ey)), (int(ew / 2), int(eh / 2)), ang, 0, 360, 255, -1)
|
||||||
|
brightness = img_cv[:, :, 0].astype(np.int32) + img_cv[:, :, 1].astype(np.int32) + img_cv[:, :, 2].astype(np.int32)
|
||||||
|
masked = np.where(mask_ellipse > 0, brightness, 0)
|
||||||
|
vals = masked[masked > 0]
|
||||||
|
if len(vals) > 0:
|
||||||
|
bth = np.percentile(vals, 90)
|
||||||
|
bmask = (masked >= bth).astype(np.uint8) * 255
|
||||||
|
bcontours, _ = cv2.findContours(bmask, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE)
|
||||||
|
if bcontours:
|
||||||
|
blargest = max(bcontours, key=cv2.contourArea)
|
||||||
|
if cv2.contourArea(blargest) >= 3 and len(blargest) >= 5:
|
||||||
|
(ix, iy), _, _ = cv2.fitEllipse(blargest)
|
||||||
|
return (float(ix), float(iy))
|
||||||
|
M = cv2.moments(blargest)
|
||||||
|
if M["m00"] > 0:
|
||||||
|
return (float(M["m10"] / M["m00"]), float(M["m01"] / M["m00"]))
|
||||||
|
return (float(ex), float(ey))
|
||||||
|
M = cv2.moments(cnt)
|
||||||
|
if M["m00"] > 0:
|
||||||
|
return (float(M["m10"] / M["m00"]), float(M["m01"] / M["m00"]))
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def find_brightest_bytes(frame, cx, cy, roi_r, th, ratio):
|
||||||
|
"""使用 frame.to_bytes() 两阶段搜索,避免 cv2 转换"""
|
||||||
|
x1 = max(0, cx - roi_r)
|
||||||
|
x2 = min(WIDTH, cx + roi_r)
|
||||||
|
y1 = max(0, cy - roi_r)
|
||||||
|
y2 = min(HEIGHT, cy + roi_r)
|
||||||
|
data = frame.to_bytes()
|
||||||
|
best_score = 0
|
||||||
|
best_pos = None
|
||||||
|
# 第一阶段:隔点粗搜
|
||||||
|
for y in range(y1, y2, 2):
|
||||||
|
for x in range(x1, x2, 2):
|
||||||
|
idx = (y * WIDTH + x) * 3
|
||||||
|
r = data[idx]; g = data[idx+1]; b = data[idx+2]
|
||||||
|
if (r > th and r > g * ratio and r > b * ratio) or \
|
||||||
|
(r > 200 and g > 200 and b > 200 and r >= g and r >= b and (r - g) > 10 and (r - b) > 10):
|
||||||
|
score = r + g + b
|
||||||
|
dx = x - cx; dy = y - cy
|
||||||
|
score *= max(0.5, 1.0 - ((dx*dx + dy*dy) ** 0.5 / roi_r) * 0.5)
|
||||||
|
if score > best_score:
|
||||||
|
best_score = score
|
||||||
|
best_pos = (x, y)
|
||||||
|
if best_pos is None:
|
||||||
|
return None
|
||||||
|
# 第二阶段:候选点 7x7 精细搜索
|
||||||
|
fx, fy = best_pos
|
||||||
|
x1f = max(0, fx - 3); x2f = min(WIDTH, fx + 4)
|
||||||
|
y1f = max(0, fy - 3); y2f = min(HEIGHT, fy + 4)
|
||||||
|
best_bright = 0
|
||||||
|
final_pos = best_pos
|
||||||
|
for y in range(y1f, y2f):
|
||||||
|
for x in range(x1f, x2f):
|
||||||
|
idx = (y * WIDTH + x) * 3
|
||||||
|
r = data[idx]; g = data[idx+1]; b = data[idx+2]
|
||||||
|
if (r > th and r > g * ratio and r > b * ratio) or \
|
||||||
|
(r > 200 and g > 200 and b > 200 and r >= g and r >= b and (r - g) > 10 and (r - b) > 10):
|
||||||
|
rgb_sum = r + g + b
|
||||||
|
if rgb_sum > best_bright:
|
||||||
|
best_bright = rgb_sum
|
||||||
|
final_pos = (float(x), float(y))
|
||||||
|
return final_pos
|
||||||
|
|
||||||
|
|
||||||
|
# 打开键盘输入设备
|
||||||
|
_key_fd = None
|
||||||
|
try:
|
||||||
|
_key_fd = open("/dev/input/event0", "rb")
|
||||||
|
except Exception:
|
||||||
|
try:
|
||||||
|
_key_fd = open("/dev/input/event1", "rb")
|
||||||
|
except Exception:
|
||||||
|
_key_fd = None
|
||||||
|
|
||||||
|
print("=" * 50)
|
||||||
|
print("激光中心点检测单元测试")
|
||||||
|
print("=" * 50)
|
||||||
|
print()
|
||||||
|
|
||||||
|
cam = camera.Camera(WIDTH, HEIGHT)
|
||||||
|
disp = display.Display()
|
||||||
|
print("[OK] 摄像头和显示初始化完成")
|
||||||
|
|
||||||
|
# 初始化激光串口
|
||||||
|
_laser_on = False
|
||||||
|
_laser_uart = None
|
||||||
|
try:
|
||||||
|
pinmap.set_pin_function("A18", "UART1_RX")
|
||||||
|
pinmap.set_pin_function("A19", "UART1_TX")
|
||||||
|
_laser_uart = uart.UART("/dev/ttyS1", 9600)
|
||||||
|
_laser_uart.read(-1)
|
||||||
|
print("[OK] 激光串口初始化完成")
|
||||||
|
except Exception as e:
|
||||||
|
print(f"[WARN] 激光串口初始化失败: {e}")
|
||||||
|
|
||||||
|
LASER_ON = bytes([0xAA, 0x00, 0x01, 0xBE, 0x00, 0x01, 0x00, 0x01, 0xC1])
|
||||||
|
LASER_OFF = bytes([0xAA, 0x00, 0x01, 0xBE, 0x00, 0x01, 0x00, 0x00, 0xC0])
|
||||||
|
|
||||||
|
# 默认开启激光
|
||||||
|
if _laser_uart:
|
||||||
|
try:
|
||||||
|
_laser_uart.write(LASER_ON)
|
||||||
|
time.sleep_ms(50)
|
||||||
|
_laser_uart.read(-1)
|
||||||
|
_laser_on = True
|
||||||
|
print("[OK] 激光已开启")
|
||||||
|
except Exception as e:
|
||||||
|
print(f"[WARN] 开启激光失败: {e}")
|
||||||
|
print()
|
||||||
|
|
||||||
|
pos_ellipse = None
|
||||||
|
pos_bright = None
|
||||||
|
frame_count = 0
|
||||||
|
use_ellipse = True
|
||||||
|
|
||||||
|
while not app.need_exit():
|
||||||
|
frame = cam.read()
|
||||||
|
if frame is None:
|
||||||
|
time.sleep_ms(10)
|
||||||
|
continue
|
||||||
|
|
||||||
|
frame_count += 1
|
||||||
|
|
||||||
|
cx, cy = WIDTH // 2, HEIGHT // 2
|
||||||
|
|
||||||
|
t0 = time.ticks_ms()
|
||||||
|
pos_bright = find_brightest_bytes(frame, cx, cy, SEARCH_RADIUS, THRESHOLD, RED_RATIO)
|
||||||
|
t1 = time.ticks_ms()
|
||||||
|
|
||||||
|
pos_ellipse = None
|
||||||
|
if _USE_CV:
|
||||||
|
img_cv = image.image2cv(frame, False, False)
|
||||||
|
t2 = time.ticks_ms()
|
||||||
|
pos_ellipse = find_ellipse(img_cv, cx, cy, SEARCH_RADIUS, THRESHOLD, RED_RATIO)
|
||||||
|
t3 = time.ticks_ms()
|
||||||
|
else:
|
||||||
|
img_cv = None
|
||||||
|
t3 = t2 = t1
|
||||||
|
|
||||||
|
dt_b = abs(time.ticks_diff(t0, t1))
|
||||||
|
dt_e = abs(time.ticks_diff(t2, t3))
|
||||||
|
|
||||||
|
if frame_count % 5 == 0:
|
||||||
|
e_str = f"({pos_ellipse[0]:.1f},{pos_ellipse[1]:.1f})" if pos_ellipse else "None"
|
||||||
|
b_str = f"({pos_bright[0]:.1f},{pos_bright[1]:.1f})" if pos_bright else "None"
|
||||||
|
print(f"[LASER] ellipse={e_str} ({dt_e}ms) brightest={b_str} ({dt_b}ms) "
|
||||||
|
f"th={THRESHOLD} ratio={RED_RATIO} radius={SEARCH_RADIUS}")
|
||||||
|
|
||||||
|
pos = pos_ellipse if use_ellipse else pos_bright
|
||||||
|
if img_cv is not None:
|
||||||
|
cv2.circle(img_cv, (cx, cy), SEARCH_RADIUS, (0, 255, 0), 1)
|
||||||
|
cv2.circle(img_cv, (cx, cy), 2, (0, 255, 0), -1)
|
||||||
|
if pos:
|
||||||
|
x, y = int(pos[0]), int(pos[1])
|
||||||
|
cv2.circle(img_cv, (x, y), 6, (0, 0, 255), 2)
|
||||||
|
cv2.line(img_cv, (x - 14, y), (x + 14, y), (0, 0, 255), 1)
|
||||||
|
cv2.line(img_cv, (x, y - 14), (x, y + 14), (0, 0, 255), 1)
|
||||||
|
cv2.putText(img_cv, f"({x},{y})", (x + 10, y - 10),
|
||||||
|
cv2.FONT_HERSHEY_SIMPLEX, 0.5, (0, 0, 255), 1, cv2.LINE_AA)
|
||||||
|
info = [
|
||||||
|
f"pos={pos if pos else 'None'}",
|
||||||
|
f"method={'ellipse' if use_ellipse else 'brightest'} th={THRESHOLD} ratio={RED_RATIO}",
|
||||||
|
f"laser={'ON' if _laser_on else 'OFF'}",
|
||||||
|
]
|
||||||
|
for i, line in enumerate(info):
|
||||||
|
cv2.putText(img_cv, line, (8, 20 + i * 22),
|
||||||
|
cv2.FONT_HERSHEY_SIMPLEX, 0.5, (0, 255, 0), 1, cv2.LINE_AA)
|
||||||
|
display_frame = image.cv2image(img_cv, False, False)
|
||||||
|
else:
|
||||||
|
display_frame = frame
|
||||||
|
|
||||||
|
disp.show(display_frame)
|
||||||
|
|
||||||
|
# 按键处理(非阻塞)
|
||||||
|
key = read_key_ev()
|
||||||
|
if key > 0:
|
||||||
|
c = chr(key & 0xFF) if key < 256 else ""
|
||||||
|
if key == 113 or key == 81 or key == 0x1b: # q/Q/ESC
|
||||||
|
break
|
||||||
|
if c == "e" or key == 18: # e
|
||||||
|
use_ellipse = not use_ellipse
|
||||||
|
print(f"[KEY] Method: {'ellipse' if use_ellipse else 'brightest'}")
|
||||||
|
if c == "l" or key == 12: # l
|
||||||
|
_laser_on = not _laser_on
|
||||||
|
if _laser_uart:
|
||||||
|
try:
|
||||||
|
_laser_uart.write(LASER_ON if _laser_on else LASER_OFF)
|
||||||
|
time.sleep_ms(30)
|
||||||
|
_laser_uart.read(-1)
|
||||||
|
print(f"[KEY] Laser: {'ON' if _laser_on else 'OFF'}")
|
||||||
|
except Exception as e:
|
||||||
|
print(f"[KEY] Laser error: {e}")
|
||||||
|
else:
|
||||||
|
print("[KEY] Laser UART not available")
|
||||||
|
time.sleep_ms(30)
|
||||||
|
|
||||||
|
# 关闭激光
|
||||||
|
if _laser_on and _laser_uart:
|
||||||
|
try:
|
||||||
|
_laser_uart.write(LASER_OFF)
|
||||||
|
_laser_uart.read(-1)
|
||||||
|
print("[EXIT] 激光已关闭")
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
print("[EXIT] 测试结束")
|
||||||
|
if _key_fd:
|
||||||
|
_key_fd.close()
|
||||||
18
test/test_yolo26.py
Normal file
18
test/test_yolo26.py
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
from maix import camera, display, image, nn, app
|
||||||
|
|
||||||
|
# 1. 初始化模型 (请确保模型文件 .mud 路径正确)
|
||||||
|
detector = nn.YOLOv5(model="/root/model_279350.mud", dual_buff=True)
|
||||||
|
|
||||||
|
# 2. 初始化摄像头,分辨率与模型输入匹配
|
||||||
|
cam = camera.Camera(detector.input_width(), detector.input_height(), detector.input_format())
|
||||||
|
disp = display.Display()
|
||||||
|
|
||||||
|
# 3. 主循环:实时检测与显示
|
||||||
|
while not app.need_exit():
|
||||||
|
img = cam.read() # 从摄像头读取一帧
|
||||||
|
objs = detector.detect(img, conf_th=0.5, iou_th=0.45) # 执行YOLO11推理
|
||||||
|
for obj in objs: # 绘制所有检测到的目标
|
||||||
|
img.draw_rect(obj.x, obj.y, obj.w, obj.h, color=image.COLOR_RED)
|
||||||
|
msg = f'{detector.labels[obj.class_id]}: {obj.score:.2f}'
|
||||||
|
img.draw_string(obj.x, obj.y, msg, color=image.COLOR_RED)
|
||||||
|
disp.show(img) # 更新屏幕显示
|
||||||
209
test/test_yolo_camera_simple.py
Normal file
209
test/test_yolo_camera_simple.py
Normal file
@@ -0,0 +1,209 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
"""
|
||||||
|
摄像头实时 YOLOv5 简易测试脚本。
|
||||||
|
|
||||||
|
特点:
|
||||||
|
- 完全独立脚本,直接 python test/test_yolo_camera_simple.py 运行,不需要传参。
|
||||||
|
- 不 import config,不依赖项目模块。
|
||||||
|
- 直接调用 maix.nn.YOLOv5(model=..., dual_buff=False)。
|
||||||
|
- camera.read() 得到的 Maix image 直接送 det.detect()。
|
||||||
|
- 在画面上画检测框、类别、置信度,并显示到屏幕。
|
||||||
|
|
||||||
|
运行环境:MaixCAM / MaixPy。
|
||||||
|
"""
|
||||||
|
|
||||||
|
import os
|
||||||
|
|
||||||
|
|
||||||
|
CAMERA_WIDTH = 640
|
||||||
|
CAMERA_HEIGHT = 480
|
||||||
|
# 默认与主项目 config.TRIANGLE_YOLO_MODEL_PATH 一致(勿用 /root/yolo26_int8.mud,那是占位路径)
|
||||||
|
_MODEL_DEFAULT = "/maixapp/apps/t11/model_270139.mud"
|
||||||
|
try:
|
||||||
|
import config as _cfg
|
||||||
|
|
||||||
|
MODEL_PATH = getattr(_cfg, "TRIANGLE_YOLO_MODEL_PATH", _MODEL_DEFAULT) or _MODEL_DEFAULT
|
||||||
|
except Exception:
|
||||||
|
MODEL_PATH = _MODEL_DEFAULT
|
||||||
|
CONF_TH = 0.7
|
||||||
|
IOU_TH = 0.45
|
||||||
|
# native: Maix detect 返回框已映射到 camera.read() 图像坐标;letterbox: 需要从网络输入坐标反算
|
||||||
|
COORD_MODE = "native"
|
||||||
|
# 只用于 DRAW_ONLY_CLASS_IDS=True 时过滤显示;默认画所有框
|
||||||
|
CLASS_IDS = (0,)
|
||||||
|
DRAW_ONLY_CLASS_IDS = False # True=只画 CLASS_IDS 里的类别;False=画所有 YOLO 返回框
|
||||||
|
|
||||||
|
|
||||||
|
def _det_obj_class_id(o):
|
||||||
|
for key in ("class_id", "cls", "label", "category", "cat_id", "id"):
|
||||||
|
if hasattr(o, key):
|
||||||
|
v = getattr(o, key)
|
||||||
|
if v is None:
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
return int(float(v))
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
continue
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _det_obj_from_seq(t):
|
||||||
|
if not isinstance(t, (list, tuple)) or len(t) < 6:
|
||||||
|
return None
|
||||||
|
|
||||||
|
class Box:
|
||||||
|
pass
|
||||||
|
|
||||||
|
b = Box()
|
||||||
|
b.x = float(t[0])
|
||||||
|
b.y = float(t[1])
|
||||||
|
b.w = float(t[2])
|
||||||
|
b.h = float(t[3])
|
||||||
|
b.score = float(t[4])
|
||||||
|
b.class_id = int(float(t[5]))
|
||||||
|
return b
|
||||||
|
|
||||||
|
|
||||||
|
def _normalize_objs(objs):
|
||||||
|
out = []
|
||||||
|
for o in objs or []:
|
||||||
|
if isinstance(o, (list, tuple)):
|
||||||
|
m = _det_obj_from_seq(o)
|
||||||
|
if m is not None:
|
||||||
|
out.append(m)
|
||||||
|
else:
|
||||||
|
out.append(o)
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def _letterbox_net_to_src_xyxy(x, y, w, h, src_w, src_h, net_w, net_h):
|
||||||
|
scale = min(net_w / float(src_w), net_h / float(src_h))
|
||||||
|
new_w = src_w * scale
|
||||||
|
new_h = src_h * scale
|
||||||
|
pad_x = (net_w - new_w) * 0.5
|
||||||
|
pad_y = (net_h - new_h) * 0.5
|
||||||
|
x0 = (x - pad_x) / scale
|
||||||
|
y0 = (y - pad_y) / scale
|
||||||
|
x1 = (x + w - pad_x) / scale
|
||||||
|
y1 = (y + h - pad_y) / scale
|
||||||
|
return x0, y0, x1, y1
|
||||||
|
|
||||||
|
|
||||||
|
def _det_to_src_xyxy(o, coord_mode, src_w, src_h, net_w, net_h):
|
||||||
|
x = float(getattr(o, "x", 0.0))
|
||||||
|
y = float(getattr(o, "y", 0.0))
|
||||||
|
w = float(getattr(o, "w", 0.0))
|
||||||
|
h = float(getattr(o, "h", 0.0))
|
||||||
|
if coord_mode in ("native", "source", "camera", "full"):
|
||||||
|
return x, y, x + w, y + h
|
||||||
|
return _letterbox_net_to_src_xyxy(x, y, w, h, src_w, src_h, net_w, net_h)
|
||||||
|
|
||||||
|
|
||||||
|
def _clip_xywh(x0, y0, x1, y1, src_w, src_h):
|
||||||
|
x0 = max(0, min(int(round(x0)), src_w - 1))
|
||||||
|
y0 = max(0, min(int(round(y0)), src_h - 1))
|
||||||
|
x1 = max(x0 + 1, min(int(round(x1)), src_w))
|
||||||
|
y1 = max(y0 + 1, min(int(round(y1)), src_h))
|
||||||
|
return x0, y0, x1 - x0, y1 - y0
|
||||||
|
|
||||||
|
|
||||||
|
def _label(det, cid):
|
||||||
|
labels = getattr(det, "labels", None)
|
||||||
|
if labels is None:
|
||||||
|
return str(cid)
|
||||||
|
try:
|
||||||
|
return str(labels[int(cid)])
|
||||||
|
except Exception:
|
||||||
|
return str(cid)
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
from maix import camera, display, nn, time, image
|
||||||
|
|
||||||
|
if not MODEL_PATH or not os.path.isfile(MODEL_PATH):
|
||||||
|
print("[ERR] 模型文件不存在:", MODEL_PATH)
|
||||||
|
return
|
||||||
|
|
||||||
|
print("[INFO] 初始化 YOLO 模型:", MODEL_PATH)
|
||||||
|
det = nn.YOLOv26(model=MODEL_PATH, dual_buff=False)
|
||||||
|
net_w = int(det.input_width())
|
||||||
|
net_h = int(det.input_height())
|
||||||
|
print(
|
||||||
|
"[INFO] net_in=%dx%d conf=%.2f iou=%.2f coord=%s class_ids=%s"
|
||||||
|
% (net_w, net_h, CONF_TH, IOU_TH, COORD_MODE, str(CLASS_IDS))
|
||||||
|
)
|
||||||
|
|
||||||
|
print("[INFO] 初始化摄像头: %dx%d" % (CAMERA_WIDTH, CAMERA_HEIGHT))
|
||||||
|
cam = camera.Camera(CAMERA_WIDTH, CAMERA_HEIGHT)
|
||||||
|
disp = display.Display()
|
||||||
|
|
||||||
|
color_cycle = []
|
||||||
|
for name in ("RED", "GREEN", "BLUE", "ORANGE", "YELLOW", "CYAN", "MAGENTA"):
|
||||||
|
c = getattr(image, "COLOR_" + name, None)
|
||||||
|
if c is not None:
|
||||||
|
color_cycle.append(c)
|
||||||
|
if not color_cycle:
|
||||||
|
color_cycle = [getattr(image, "COLOR_RED", 0)]
|
||||||
|
|
||||||
|
frame_idx = 0
|
||||||
|
last_log_ms = time.ticks_ms()
|
||||||
|
fps_count = 0
|
||||||
|
|
||||||
|
while True:
|
||||||
|
frame = cam.read()
|
||||||
|
src_w = frame.width()
|
||||||
|
src_h = frame.height()
|
||||||
|
|
||||||
|
t0 = time.ticks_ms()
|
||||||
|
raw = det.detect(frame, conf_th=CONF_TH, iou_th=IOU_TH)
|
||||||
|
detect_ms = time.ticks_ms() - t0
|
||||||
|
objs = _normalize_objs(raw if raw is not None else [])
|
||||||
|
|
||||||
|
draw_count = 0
|
||||||
|
for i, o in enumerate(objs):
|
||||||
|
cid = _det_obj_class_id(o)
|
||||||
|
if cid is None:
|
||||||
|
cid = -1
|
||||||
|
if DRAW_ONLY_CLASS_IDS and cid not in CLASS_IDS:
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
score = float(getattr(o, "score", 0.0))
|
||||||
|
except Exception:
|
||||||
|
score = 0.0
|
||||||
|
|
||||||
|
x0, y0, x1, y1 = _det_to_src_xyxy(o, COORD_MODE, src_w, src_h, net_w, net_h)
|
||||||
|
ix, iy, iw, ih = _clip_xywh(x0, y0, x1, y1, src_w, src_h)
|
||||||
|
col = color_cycle[cid % len(color_cycle)] if cid >= 0 else color_cycle[0]
|
||||||
|
frame.draw_rect(ix, iy, iw, ih, color=col)
|
||||||
|
frame.draw_string(ix, max(0, iy - 16), "%s %.2f" % (_label(det, cid), score), color=col)
|
||||||
|
draw_count += 1
|
||||||
|
|
||||||
|
frame.draw_string(4, 4, "YOLO boxes:%d draw:%d %dms" % (len(objs), draw_count, detect_ms), color=color_cycle[0])
|
||||||
|
disp.show(frame)
|
||||||
|
|
||||||
|
frame_idx += 1
|
||||||
|
fps_count += 1
|
||||||
|
now = time.ticks_ms()
|
||||||
|
if now - last_log_ms >= 1000:
|
||||||
|
print(
|
||||||
|
"[INFO] frame=%d fps=%d raw_boxes=%d draw_boxes=%d detect_ms=%d"
|
||||||
|
% (frame_idx, fps_count, len(objs), draw_count, detect_ms)
|
||||||
|
)
|
||||||
|
fps_count = 0
|
||||||
|
last_log_ms = now
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
try:
|
||||||
|
main()
|
||||||
|
except KeyboardInterrupt:
|
||||||
|
print("[INFO] exit")
|
||||||
|
except Exception as e:
|
||||||
|
print("[ERR]", e)
|
||||||
|
try:
|
||||||
|
import traceback
|
||||||
|
|
||||||
|
traceback.print_exc()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
29
test/test_yolov8.py
Normal file
29
test/test_yolov8.py
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
from maix import image, nn, display
|
||||||
|
|
||||||
|
# 1. 加载模型
|
||||||
|
detector = nn.YOLOv8(model="/root/279350.mud", dual_buff=False)
|
||||||
|
# 2. 加载指定图片(根据模型输入尺寸自动缩放宽高)
|
||||||
|
img = image.load("/root/tes.jpg")
|
||||||
|
if img is None:
|
||||||
|
raise FileNotFoundError("图片加载失败,请检查路径")
|
||||||
|
|
||||||
|
# 3. 调整图片尺寸到模型输入要求(可选,detect内部会处理,但提前缩放可提高速度)
|
||||||
|
# img = img.resize(detector.input_width(), detector.input_height())
|
||||||
|
|
||||||
|
# 4. 检测
|
||||||
|
objs = detector.detect(img, conf_th=0.5, iou_th=0.45)
|
||||||
|
|
||||||
|
# 5. 在图片上绘制结果
|
||||||
|
for obj in objs:
|
||||||
|
img.draw_rect(obj.x, obj.y, obj.w, obj.h, color=image.COLOR_RED)
|
||||||
|
msg = f'{detector.labels[obj.class_id]}: {obj.score:.2f}'
|
||||||
|
img.draw_string(obj.x, obj.y, msg, color=image.COLOR_RED)
|
||||||
|
|
||||||
|
# 6. 显示结果(如果设备有屏幕)
|
||||||
|
disp = display.Display()
|
||||||
|
disp.show(img)
|
||||||
|
|
||||||
|
# 7. 保存结果(可选)
|
||||||
|
img.save("/root/result.jpg")
|
||||||
|
|
||||||
|
print("识别完成,结果已显示并保存为 result.jpg")
|
||||||
@@ -22,6 +22,143 @@ def _log(msg):
|
|||||||
pass
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
def _read_triangle_direction_cfg():
|
||||||
|
"""读取 config 中三角形方向/中心距校验参数。"""
|
||||||
|
try:
|
||||||
|
import config as cfg
|
||||||
|
return {
|
||||||
|
"enable": bool(getattr(cfg, "TRIANGLE_DIRECTION_VALIDATE_ENABLE", True)),
|
||||||
|
"min_pass": int(getattr(cfg, "TRIANGLE_DIRECTION_MIN_PASS", 3)),
|
||||||
|
"dot_min": float(getattr(cfg, "TRIANGLE_DIRECTION_DOT_MIN", 0.0)),
|
||||||
|
"to_center_dot_min": float(
|
||||||
|
getattr(cfg, "TRIANGLE_DIRECTION_TO_CENTER_DOT_MIN", 0.35)
|
||||||
|
),
|
||||||
|
"center_dist_enable": bool(
|
||||||
|
getattr(cfg, "TRIANGLE_CENTER_DISTANCE_VALIDATE_ENABLE", True)
|
||||||
|
),
|
||||||
|
"center_dist_tol": float(
|
||||||
|
getattr(cfg, "TRIANGLE_CENTER_DISTANCE_RATIO_TOL", 0.45)
|
||||||
|
),
|
||||||
|
}
|
||||||
|
except Exception:
|
||||||
|
return {
|
||||||
|
"enable": True,
|
||||||
|
"min_pass": 3,
|
||||||
|
"dot_min": 0.0,
|
||||||
|
"to_center_dot_min": 0.35,
|
||||||
|
"center_dist_enable": True,
|
||||||
|
"center_dist_tol": 0.45,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _quad_combo_orient_penalty(cands_4):
|
||||||
|
"""
|
||||||
|
四点组合评分用的方向惩罚(原 _score_quad 内 orient_pen 逻辑)。
|
||||||
|
TRIANGLE_DIRECTION_VALIDATE_ENABLE=False 时调用方应跳过(不加罚)。
|
||||||
|
"""
|
||||||
|
orient_pen = 0.0
|
||||||
|
orient_vote = []
|
||||||
|
for c in cands_4:
|
||||||
|
cen = np.array(c["center_px"], dtype=np.float32)
|
||||||
|
rpt = np.array(c["right_pt"], dtype=np.float32)
|
||||||
|
vx = float(cen[0] - rpt[0])
|
||||||
|
vy = float(cen[1] - rpt[1])
|
||||||
|
if abs(vx) < 1e-6 or abs(vy) < 1e-6:
|
||||||
|
orient_pen += 1.0
|
||||||
|
orient_vote.append(None)
|
||||||
|
continue
|
||||||
|
if abs(vx) < abs(vy) * 0.15 or abs(vy) < abs(vx) * 0.15:
|
||||||
|
orient_pen += 0.5
|
||||||
|
if vx > 0 and vy > 0:
|
||||||
|
orient_vote.append(0)
|
||||||
|
elif vx < 0 and vy > 0:
|
||||||
|
orient_vote.append(1)
|
||||||
|
elif vx > 0 and vy < 0:
|
||||||
|
orient_vote.append(2)
|
||||||
|
else:
|
||||||
|
orient_vote.append(3)
|
||||||
|
valid_votes = [v for v in orient_vote if v is not None]
|
||||||
|
if valid_votes:
|
||||||
|
from collections import Counter
|
||||||
|
vc = Counter(valid_votes)
|
||||||
|
orient_pen += max(0, max(vc.values()) - 1) * 0.8
|
||||||
|
return orient_pen
|
||||||
|
|
||||||
|
|
||||||
|
def _marker_inward_unit(marker):
|
||||||
|
"""从直角顶点指向三角内部的单位向量;marker['center'] 为直角顶点。"""
|
||||||
|
right = np.array(marker["center"], dtype=np.float64)
|
||||||
|
corners = marker.get("corners")
|
||||||
|
if not corners or len(corners) < 3:
|
||||||
|
return None
|
||||||
|
cen = np.mean(np.array(corners, dtype=np.float64), axis=0)
|
||||||
|
inv = cen - right
|
||||||
|
n = float(np.linalg.norm(inv))
|
||||||
|
if n < 1e-6:
|
||||||
|
return None
|
||||||
|
return inv / n
|
||||||
|
|
||||||
|
|
||||||
|
def _validate_triangle_direction(marker_centers, tri_markers, cfg):
|
||||||
|
"""
|
||||||
|
校验:四角到候选靶心距离近似一致;各真实黑三角朝向靶心。
|
||||||
|
仅统计 tri_markers 中真实检出的角(不含几何补全的虚拟点)。
|
||||||
|
Returns:
|
||||||
|
(ok: bool, reason: str)
|
||||||
|
"""
|
||||||
|
if not cfg.get("enable", True):
|
||||||
|
return True, ""
|
||||||
|
|
||||||
|
pts = np.array(marker_centers, dtype=np.float64).reshape(-1, 2)
|
||||||
|
if len(pts) < 3:
|
||||||
|
return True, ""
|
||||||
|
|
||||||
|
quad_center = np.mean(pts, axis=0)
|
||||||
|
|
||||||
|
if cfg.get("center_dist_enable", True) and len(pts) >= 3:
|
||||||
|
dists = np.linalg.norm(pts - quad_center, axis=1)
|
||||||
|
mean_d = float(np.mean(dists))
|
||||||
|
if mean_d > 1e-6:
|
||||||
|
ratio = (float(np.max(dists)) - float(np.min(dists))) / mean_d
|
||||||
|
tol = float(cfg.get("center_dist_tol", 0.45))
|
||||||
|
if ratio > tol:
|
||||||
|
return False, f"center_dist_ratio={ratio:.2f}>{tol:.2f}"
|
||||||
|
|
||||||
|
dot_need = max(
|
||||||
|
float(cfg.get("dot_min", 0.0)),
|
||||||
|
float(cfg.get("to_center_dot_min", 0.35)),
|
||||||
|
)
|
||||||
|
pass_n = 0
|
||||||
|
check_n = 0
|
||||||
|
for m in tri_markers or []:
|
||||||
|
if m.get("center") is None:
|
||||||
|
continue
|
||||||
|
check_n += 1
|
||||||
|
right = np.array(m["center"], dtype=np.float64)
|
||||||
|
to_center = quad_center - right
|
||||||
|
nc = float(np.linalg.norm(to_center))
|
||||||
|
if nc < 1e-6:
|
||||||
|
continue
|
||||||
|
inward = _marker_inward_unit(m)
|
||||||
|
if inward is None:
|
||||||
|
continue
|
||||||
|
dot_tc = float(np.dot(inward, to_center / nc))
|
||||||
|
if dot_tc >= dot_need:
|
||||||
|
pass_n += 1
|
||||||
|
|
||||||
|
if check_n == 0:
|
||||||
|
return True, ""
|
||||||
|
|
||||||
|
min_pass = int(cfg.get("min_pass", 3))
|
||||||
|
min_pass = max(1, min(min_pass, check_n))
|
||||||
|
if pass_n < min_pass:
|
||||||
|
return False, (
|
||||||
|
f"direction_pass={pass_n}/{check_n} need>={min_pass} "
|
||||||
|
f"(dot>={dot_need:.2f})"
|
||||||
|
)
|
||||||
|
return True, ""
|
||||||
|
|
||||||
|
|
||||||
def _gray_suppress_bright_by_v(img_rgb, v_above: int):
|
def _gray_suppress_bright_by_v(img_rgb, v_above: int):
|
||||||
"""
|
"""
|
||||||
RGB 输入:在 HSV 的 V 上,将亮度 >= v_above 的像素灰度置为 255。
|
RGB 输入:在 HSV 的 V 上,将亮度 >= v_above 的像素灰度置为 255。
|
||||||
@@ -224,7 +361,7 @@ def detect_triangle_markers(
|
|||||||
blackhat_kernel_frac = 0.018
|
blackhat_kernel_frac = 0.018
|
||||||
try:
|
try:
|
||||||
import config as _tcfg
|
import config as _tcfg
|
||||||
_timing_log = bool(getattr(_tcfg, "TRIANGLE_TIMING_LOG", True))
|
_timing_log = bool(getattr(_tcfg, "ARCHERY_TIMING_ENABLE", True)) and bool(getattr(_tcfg, "TRIANGLE_TIMING_LOG", True))
|
||||||
except Exception:
|
except Exception:
|
||||||
_timing_log = True
|
_timing_log = True
|
||||||
|
|
||||||
@@ -622,6 +759,8 @@ def detect_triangle_markers(
|
|||||||
bot_pair = sorted(by_y[2:], key=lambda i: pts_4[i][0])
|
bot_pair = sorted(by_y[2:], key=lambda i: pts_4[i][0])
|
||||||
return top_pair[0], bot_pair[0], bot_pair[1], top_pair[1]
|
return top_pair[0], bot_pair[0], bot_pair[1], top_pair[1]
|
||||||
|
|
||||||
|
_dir_cfg_combo = _read_triangle_direction_cfg()
|
||||||
|
|
||||||
def _score_quad(cands_4):
|
def _score_quad(cands_4):
|
||||||
pts = [np.array(c["center_px"]) for c in cands_4]
|
pts = [np.array(c["center_px"]) for c in cands_4]
|
||||||
legs = [c["avg_leg"] for c in cands_4]
|
legs = [c["avg_leg"] for c in cands_4]
|
||||||
@@ -641,7 +780,13 @@ def detect_triangle_markers(
|
|||||||
med_l = float(np.median(legs))
|
med_l = float(np.median(legs))
|
||||||
leg_dev = max(abs(l - med_l) / (med_l + 1e-6) for l in legs)
|
leg_dev = max(abs(l - med_l) / (med_l + 1e-6) for l in legs)
|
||||||
|
|
||||||
score = (diag_ratio - 1.0) * 3.0 + (h_ratio - 1.0) + (v_ratio - 1.0) + leg_dev * 2.0
|
orient_pen = (
|
||||||
|
_quad_combo_orient_penalty(cands_4)
|
||||||
|
if _dir_cfg_combo.get("enable", True)
|
||||||
|
else 0.0
|
||||||
|
)
|
||||||
|
|
||||||
|
score = (diag_ratio - 1.0) * 3.0 + (h_ratio - 1.0) + (v_ratio - 1.0) + leg_dev * 2.0 + orient_pen
|
||||||
return score, (tl, bl, br, tr)
|
return score, (tl, bl, br, tr)
|
||||||
|
|
||||||
assigned = None
|
assigned = None
|
||||||
@@ -932,6 +1077,8 @@ def _assign_marker_ids_from_filtered(filtered, verbose=True):
|
|||||||
bot_pair = sorted(by_y[2:], key=lambda i: pts_4[i][0])
|
bot_pair = sorted(by_y[2:], key=lambda i: pts_4[i][0])
|
||||||
return top_pair[0], bot_pair[0], bot_pair[1], top_pair[1]
|
return top_pair[0], bot_pair[0], bot_pair[1], top_pair[1]
|
||||||
|
|
||||||
|
_dir_cfg_combo = _read_triangle_direction_cfg()
|
||||||
|
|
||||||
def _score_quad(cands_4):
|
def _score_quad(cands_4):
|
||||||
pts = [np.array(c["center_px"]) for c in cands_4]
|
pts = [np.array(c["center_px"]) for c in cands_4]
|
||||||
legs = [c["avg_leg"] for c in cands_4]
|
legs = [c["avg_leg"] for c in cands_4]
|
||||||
@@ -947,7 +1094,12 @@ def _assign_marker_ids_from_filtered(filtered, verbose=True):
|
|||||||
v_ratio = max(s_left, s_right) / (min(s_left, s_right) + 1e-6)
|
v_ratio = max(s_left, s_right) / (min(s_left, s_right) + 1e-6)
|
||||||
med_l = float(np.median(legs))
|
med_l = float(np.median(legs))
|
||||||
leg_dev = max(abs(l - med_l) / (med_l + 1e-6) for l in legs)
|
leg_dev = max(abs(l - med_l) / (med_l + 1e-6) for l in legs)
|
||||||
score = (diag_ratio - 1.0) * 3.0 + (h_ratio - 1.0) + (v_ratio - 1.0) + leg_dev * 2.0
|
orient_pen = (
|
||||||
|
_quad_combo_orient_penalty(cands_4)
|
||||||
|
if _dir_cfg_combo.get("enable", True)
|
||||||
|
else 0.0
|
||||||
|
)
|
||||||
|
score = (diag_ratio - 1.0) * 3.0 + (h_ratio - 1.0) + (v_ratio - 1.0) + leg_dev * 2.0 + orient_pen
|
||||||
return score, (tl, bl, br, tr)
|
return score, (tl, bl, br, tr)
|
||||||
|
|
||||||
assigned = None
|
assigned = None
|
||||||
@@ -1113,7 +1265,7 @@ def try_triangle_scoring(
|
|||||||
|
|
||||||
try:
|
try:
|
||||||
import config as _cfg_tl
|
import config as _cfg_tl
|
||||||
_try_timing_log = bool(getattr(_cfg_tl, "TRIANGLE_TIMING_LOG", True))
|
_try_timing_log = bool(getattr(_cfg_tl, "ARCHERY_TIMING_ENABLE", True)) and bool(getattr(_cfg_tl, "TRIANGLE_TIMING_LOG", True))
|
||||||
_crop_min_side = int(getattr(_cfg_tl, "TRIANGLE_CROP_ROI_MIN_SIDE_PX", 64))
|
_crop_min_side = int(getattr(_cfg_tl, "TRIANGLE_CROP_ROI_MIN_SIDE_PX", 64))
|
||||||
except Exception:
|
except Exception:
|
||||||
_try_timing_log = True
|
_try_timing_log = True
|
||||||
@@ -1733,6 +1885,21 @@ def try_triangle_scoring(
|
|||||||
"is_virtual": bool(_is_virtual),
|
"is_virtual": bool(_is_virtual),
|
||||||
})
|
})
|
||||||
|
|
||||||
|
# ---------- 方向 / 中心距校验(config.TRIANGLE_DIRECTION_*) ----------
|
||||||
|
_dir_cfg = _read_triangle_direction_cfg()
|
||||||
|
_dir_ok, _dir_reason = _validate_triangle_direction(
|
||||||
|
marker_centers, tri_markers, _dir_cfg
|
||||||
|
)
|
||||||
|
if not _dir_ok:
|
||||||
|
_log(f"[TRI] 方向校验失败: {_dir_reason}")
|
||||||
|
if _try_timing_log:
|
||||||
|
_log(
|
||||||
|
f"[TRI] timing_ms(try_triangle): {_tri_yolo_part} "
|
||||||
|
f"geometry={(time.perf_counter() - _t_seg) * 1000:.1f} "
|
||||||
|
f"total_try={(time.perf_counter() - _t_try0) * 1000:.1f} (方向校验失败)"
|
||||||
|
)
|
||||||
|
return out
|
||||||
|
|
||||||
# ---------- 结果有效性校验(防 nan/inf 与退化角点) ----------
|
# ---------- 结果有效性校验(防 nan/inf 与退化角点) ----------
|
||||||
try:
|
try:
|
||||||
import config as _cfg
|
import config as _cfg
|
||||||
|
|||||||
28
version.md
28
version.md
@@ -1,28 +0,0 @@
|
|||||||
# 1.2.0 开始使用C++编译成.so,替换部分代码
|
|
||||||
# 1.2.1 ota使用加密包
|
|
||||||
# 1.2.2 支持wifi ota,并且设定时区,并使用单独线程保存图片
|
|
||||||
# 1.2.3 修改ADC_TRIGGER_THRESHOLD 为2300,支持上传日志到服务器
|
|
||||||
# 1.2.4 修改ADC_TRIGGER_THRESHOLD 为3000,并默认关闭摄像头的显示,并把ADC的采样间隔从50ms降低到10ms
|
|
||||||
# 1.2.5 支持空气传感器采样,并默认关闭日志。优化断网时的发送队列丢消息问题,解决 WiFi 断线检测不可靠问题。
|
|
||||||
# 1.2.6 在链接 wifi 前先判断 wifi 的可用性,假如不可用,则不落盘。增加日志批量压缩上传功能
|
|
||||||
# 1.2.7 修复OTA失败的bug, 空气压力传感器的阈值是2500
|
|
||||||
# 1.2.8 (1) 加快 wifi 下数据传输的速度。(2) 调整射箭时处理的逻辑,优先上报数据,再存照片之类的操作。(3)假如是用户打开激光的,射箭触发后不再关闭激光,因为是调瞄阶段
|
|
||||||
# 1.2.9 增加电源板的控制和自动关机的功能
|
|
||||||
# 1.2.10 config formal
|
|
||||||
# 1.2.11 增加三角形的单应性算法,适配对应的靶纸
|
|
||||||
# 1.2.110 关掉了黑色三角形算法,只用于测试
|
|
||||||
# 1.2.13 修改wifi连接
|
|
||||||
# 1.2.14 修改了icc登录部分
|
|
||||||
# 2.15.3 新版本ota,去除ai算环数方法
|
|
||||||
# 2.15.4 更新版本号
|
|
||||||
# 2.15.5 打印ota进度
|
|
||||||
# 2.15.6 更新版本号
|
|
||||||
# 2.15.7 更新版本号
|
|
||||||
# 2.15.8 启动不加载预加载yolo
|
|
||||||
# 2.15.9 20cm
|
|
||||||
# 2.15.10 不保存图片
|
|
||||||
# 2.15.11 优化内存
|
|
||||||
# 2.15.12 优化算法
|
|
||||||
# 2.15.13 优化算法
|
|
||||||
# 2.15.14 优化算法
|
|
||||||
# 2.15.15 优化wifi连接
|
|
||||||
24
version.py
24
version.py
@@ -4,6 +4,28 @@
|
|||||||
应用版本号
|
应用版本号
|
||||||
每次 OTA 更新时,只需要更新这个文件中的版本号
|
每次 OTA 更新时,只需要更新这个文件中的版本号
|
||||||
"""
|
"""
|
||||||
VERSION = '2.15.15'
|
VERSION = '1.2.15.1'
|
||||||
|
|
||||||
|
|
||||||
|
# 1.2.0 开始使用C++编译成.so,替换部分代码
|
||||||
|
# 1.2.1 ota使用加密包
|
||||||
|
# 1.2.2 支持wifi ota,并且设定时区,并使用单独线程保存图片
|
||||||
|
# 1.2.3 修改ADC_TRIGGER_THRESHOLD 为2300,支持上传日志到服务器
|
||||||
|
# 1.2.4 修改ADC_TRIGGER_THRESHOLD 为3000,并默认关闭摄像头的显示,并把ADC的采样间隔从50ms降低到10ms
|
||||||
|
# 1.2.5 支持空气传感器采样,并默认关闭日志。优化断网时的发送队列丢消息问题,解决 WiFi 断线检测不可靠问题。
|
||||||
|
# 1.2.6 在链接 wifi 前先判断 wifi 的可用性,假如不可用,则不落盘。增加日志批量压缩上传功能
|
||||||
|
# 1.2.7 修复OTA失败的bug, 空气压力传感器的阈值是2500
|
||||||
|
# 1.2.8 (1) 加快 wifi 下数据传输的速度。(2) 调整射箭时处理的逻辑,优先上报数据,再存照片之类的操作。(3)假如是用户打开激光的,射箭触发后不再关闭激光,因为是调瞄阶段
|
||||||
|
# 1.2.9 增加电源板的控制和自动关机的功能
|
||||||
|
# 1.2.10 config formal
|
||||||
|
# 1.2.11 增加三角形的单应性算法,适配对应的靶纸
|
||||||
|
# 1.2.110 关掉了黑色三角形算法,只用于测试
|
||||||
|
# 1.2.13 修改wifi连接
|
||||||
|
# 1.2.14 修改了icc登录部分
|
||||||
|
# 1.2.15.1 增加了标靶判断 20 40
|
||||||
|
# 1.2.16.1 增加激光校准,三角形方向判断,时间开关
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
170
vision.py
170
vision.py
@@ -10,6 +10,7 @@ import os
|
|||||||
import math
|
import math
|
||||||
import threading
|
import threading
|
||||||
import queue
|
import queue
|
||||||
|
import time
|
||||||
from maix import image
|
from maix import image
|
||||||
import config
|
import config
|
||||||
from logger_manager import logger_manager
|
from logger_manager import logger_manager
|
||||||
@@ -531,11 +532,14 @@ def detect_circle_v3(frame, laser_point=None, img_cv=None):
|
|||||||
if img_cv is None:
|
if img_cv is None:
|
||||||
img_cv = image.image2cv(frame, False, False)
|
img_cv = image.image2cv(frame, False, False)
|
||||||
logger = logger_manager.logger
|
logger = logger_manager.logger
|
||||||
|
_timing_on = bool(getattr(config, "VISION_TIMING_ENABLE", True))
|
||||||
|
_t0 = time.perf_counter() if _timing_on else None
|
||||||
|
_t1 = _t2 = _t3 = _t4 = _t5 = None
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
logger.debug(f"[detect_circle_v3] begin {datetime.now()}")
|
logger.debug(f"[detect_circle_v3] begin {datetime.now()}")
|
||||||
# -- 1. 缩图加速(与三角形路径保持一致)
|
# -- 1. 缩图加速(与三角形路径保持一致)
|
||||||
h_orig, w_orig = img_cv.shape[:2]
|
h_orig, w_orig = img_cv.shape[:2]
|
||||||
MAX_DET_DIM = 480
|
MAX_DET_DIM = 320
|
||||||
long_side = max(h_orig, w_orig)
|
long_side = max(h_orig, w_orig)
|
||||||
if long_side > MAX_DET_DIM:
|
if long_side > MAX_DET_DIM:
|
||||||
det_scale = MAX_DET_DIM / long_side
|
det_scale = MAX_DET_DIM / long_side
|
||||||
@@ -554,6 +558,8 @@ def detect_circle_v3(frame, laser_point=None, img_cv=None):
|
|||||||
ellipse_params = None
|
ellipse_params = None
|
||||||
|
|
||||||
logger.debug(f"[detect_circle_v3] step 1 fin {datetime.now()}")
|
logger.debug(f"[detect_circle_v3] step 1 fin {datetime.now()}")
|
||||||
|
if _timing_on:
|
||||||
|
_t1 = time.perf_counter()
|
||||||
|
|
||||||
# -- 2. HSV + 黄色掩码
|
# -- 2. HSV + 黄色掩码
|
||||||
hsv = cv2.cvtColor(img_det, cv2.COLOR_RGB2HSV)
|
hsv = cv2.cvtColor(img_det, cv2.COLOR_RGB2HSV)
|
||||||
@@ -567,25 +573,26 @@ def detect_circle_v3(frame, laser_point=None, img_cv=None):
|
|||||||
mask_yellow = cv2.morphologyEx(mask_yellow, cv2.MORPH_CLOSE, kernel)
|
mask_yellow = cv2.morphologyEx(mask_yellow, cv2.MORPH_CLOSE, kernel)
|
||||||
|
|
||||||
logger.debug(f"[detect_circle_v3] step 2 fin {datetime.now()}")
|
logger.debug(f"[detect_circle_v3] step 2 fin {datetime.now()}")
|
||||||
|
if _timing_on:
|
||||||
|
_t2 = time.perf_counter()
|
||||||
|
_t3 = time.perf_counter()
|
||||||
|
|
||||||
# -- 3. 红色掩码:在循环外只算一次
|
# -- 3. 红色掩码:在循环外只算一次
|
||||||
mask_red = cv2.bitwise_or(
|
mask_red = cv2.bitwise_or(
|
||||||
cv2.inRange(hsv, np.array([0, 30, 20]), np.array([12, 255, 255])),
|
cv2.inRange(hsv, np.array([0, 80, 0]), np.array([10, 255, 255])),
|
||||||
cv2.inRange(hsv, np.array([168, 30, 20]), np.array([180, 255, 255])),
|
cv2.inRange(hsv, np.array([170, 80, 0]), np.array([180, 255, 255])),
|
||||||
)
|
)
|
||||||
kernel_red = cv2.getStructuringElement(cv2.MORPH_ELLIPSE, (5, 5))
|
kernel_red = cv2.getStructuringElement(cv2.MORPH_ELLIPSE, (5, 5))
|
||||||
mask_red = cv2.morphologyEx(mask_red, cv2.MORPH_CLOSE, kernel_red)
|
mask_red = cv2.morphologyEx(mask_red, cv2.MORPH_CLOSE, kernel_red)
|
||||||
# 再加一次膨胀,加厚环状区域避免碎片化
|
|
||||||
mask_red = cv2.dilate(mask_red, kernel_red, iterations=1)
|
|
||||||
contours_red, _ = cv2.findContours(mask_red, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE)
|
contours_red, _ = cv2.findContours(mask_red, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE)
|
||||||
# 预先把红色轮廓筛选成 (center, radius) 列表,后续直接查表
|
# 预先把红色轮廓筛选成 (center, radius) 列表,后续直接查表
|
||||||
red_candidates = []
|
red_candidates = []
|
||||||
for cnt_r in contours_red:
|
for cnt_r in contours_red:
|
||||||
ar = cv2.contourArea(cnt_r)
|
ar = cv2.contourArea(cnt_r)
|
||||||
if ar <= 10:
|
if ar <= 50:
|
||||||
continue
|
continue
|
||||||
pr = cv2.arcLength(cnt_r, True)
|
pr = cv2.arcLength(cnt_r, True)
|
||||||
if pr <= 0 or (4 * np.pi * ar) / (pr * pr) <= 0.2:
|
if pr <= 0 or (4 * np.pi * ar) / (pr * pr) <= 0.6:
|
||||||
continue
|
continue
|
||||||
if len(cnt_r) >= 5:
|
if len(cnt_r) >= 5:
|
||||||
(xr, yr), (wr, hr), _ = cv2.fitEllipse(cnt_r)
|
(xr, yr), (wr, hr), _ = cv2.fitEllipse(cnt_r)
|
||||||
@@ -595,19 +602,22 @@ def detect_circle_v3(frame, laser_point=None, img_cv=None):
|
|||||||
red_candidates.append({"center": (int(xr), int(yr)), "radius": int(rr)})
|
red_candidates.append({"center": (int(xr), int(yr)), "radius": int(rr)})
|
||||||
|
|
||||||
logger.debug(f"[detect_circle_v3] step 3 fin {datetime.now()}")
|
logger.debug(f"[detect_circle_v3] step 3 fin {datetime.now()}")
|
||||||
|
if _timing_on:
|
||||||
|
_t3 = time.perf_counter()
|
||||||
|
_t4 = time.perf_counter()
|
||||||
|
|
||||||
# -- 4. 黄色轮廓循环(复用上面的红色候选列表)
|
# -- 4. 黄色轮廓循环(复用上面的红色候选列表)
|
||||||
contours_yellow, _ = cv2.findContours(mask_yellow, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE)
|
contours_yellow, _ = cv2.findContours(mask_yellow, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE)
|
||||||
valid_targets = []
|
valid_targets = []
|
||||||
for cnt_yellow in contours_yellow:
|
for cnt_yellow in contours_yellow:
|
||||||
area = cv2.contourArea(cnt_yellow)
|
area = cv2.contourArea(cnt_yellow)
|
||||||
if area <= 15:
|
if area <= 50:
|
||||||
continue
|
continue
|
||||||
perimeter = cv2.arcLength(cnt_yellow, True)
|
perimeter = cv2.arcLength(cnt_yellow, True)
|
||||||
if perimeter <= 0:
|
if perimeter <= 0:
|
||||||
continue
|
continue
|
||||||
circularity = (4 * np.pi * area) / (perimeter * perimeter)
|
circularity = (4 * np.pi * area) / (perimeter * perimeter)
|
||||||
if circularity <= 0.5:
|
if circularity <= 0.7:
|
||||||
continue
|
continue
|
||||||
if logger:
|
if logger:
|
||||||
logger.info(f"[target] -> 面积:{area:.1f}, 圆度:{circularity:.2f}")
|
logger.info(f"[target] -> 面积:{area:.1f}, 圆度:{circularity:.2f}")
|
||||||
@@ -627,11 +637,7 @@ def detect_circle_v3(frame, laser_point=None, img_cv=None):
|
|||||||
ddx = yellow_center[0] - rc["center"][0]
|
ddx = yellow_center[0] - rc["center"][0]
|
||||||
ddy = yellow_center[1] - rc["center"][1]
|
ddy = yellow_center[1] - rc["center"][1]
|
||||||
dist_centers = math.hypot(ddx, ddy)
|
dist_centers = math.hypot(ddx, ddy)
|
||||||
max_dist = yellow_radius * 2.0
|
if dist_centers < yellow_radius * 1.5 and rc["radius"] > yellow_radius * 0.8:
|
||||||
min_r = min(rc["radius"], yellow_radius)
|
|
||||||
max_r = max(rc["radius"], yellow_radius)
|
|
||||||
size_ratio = min_r / max_r if max_r > 0 else 0
|
|
||||||
if dist_centers < max_dist and size_ratio > 0.5:
|
|
||||||
if logger:
|
if logger:
|
||||||
logger.info(f"[target] -> 找到匹配的红圈: 黄心({yellow_center}), "
|
logger.info(f"[target] -> 找到匹配的红圈: 黄心({yellow_center}), "
|
||||||
f"红心({rc['center']}), 距离:{dist_centers:.1f}, "
|
f"红心({rc['center']}), 距离:{dist_centers:.1f}, "
|
||||||
@@ -644,19 +650,13 @@ def detect_circle_v3(frame, laser_point=None, img_cv=None):
|
|||||||
})
|
})
|
||||||
matched = True
|
matched = True
|
||||||
break
|
break
|
||||||
if not matched:
|
if not matched and logger:
|
||||||
# 黄圈高置信度兜底:大且圆时跳过红圈验证
|
logger.debug("Debug -> 未找到匹配的红色圆圈,可能是误识别")
|
||||||
if area > 30 and circularity > 0.8:
|
|
||||||
valid_targets.append({
|
|
||||||
"center": yellow_center,
|
|
||||||
"radius": yellow_radius,
|
|
||||||
"ellipse": yellow_ellipse,
|
|
||||||
"area": area,
|
|
||||||
})
|
|
||||||
elif logger:
|
|
||||||
logger.debug("Debug -> 未找到匹配的红色圆圈,可能是误识别")
|
|
||||||
|
|
||||||
logger.debug(f"[detect_circle_v3] step 4 fin {datetime.now()}")
|
logger.debug(f"[detect_circle_v3] step 4 fin {datetime.now()}")
|
||||||
|
if _timing_on:
|
||||||
|
_t4 = time.perf_counter()
|
||||||
|
_t5 = time.perf_counter()
|
||||||
|
|
||||||
# -- 5. 选最佳目标,坐标还原到原始分辨率
|
# -- 5. 选最佳目标,坐标还原到原始分辨率
|
||||||
if valid_targets:
|
if valid_targets:
|
||||||
@@ -684,7 +684,20 @@ def detect_circle_v3(frame, laser_point=None, img_cv=None):
|
|||||||
ellipse_params = be
|
ellipse_params = be
|
||||||
best_radius1 = best_radius * 5
|
best_radius1 = best_radius * 5
|
||||||
result_img = image.cv2image(img_cv, False, False)
|
result_img = image.cv2image(img_cv, False, False)
|
||||||
logger.debug(f"[detect_circle_v3] step 5 fin {datetime.now()}")
|
if _timing_on:
|
||||||
|
_t5 = time.perf_counter()
|
||||||
|
_t_all = (_t5 - _t0) * 1000
|
||||||
|
_ms1 = (_t1 - _t0) * 1000
|
||||||
|
_ms2 = (_t2 - _t1) * 1000
|
||||||
|
_ms3 = (_t3 - _t2) * 1000
|
||||||
|
_ms4 = (_t4 - _t3) * 1000
|
||||||
|
_ms5 = (_t5 - _t4) * 1000
|
||||||
|
logger.info(
|
||||||
|
f"[VISION timing] total={_t_all:.1f}ms "
|
||||||
|
f"resize={_ms1:.1f} hsv_yellow={_ms2:.1f} "
|
||||||
|
f"red_mask={_ms3:.1f} yellow_loop={_ms4:.1f} "
|
||||||
|
f"select_cv2img={_ms5:.1f}"
|
||||||
|
)
|
||||||
return result_img, best_center, best_radius, method, best_radius1, ellipse_params
|
return result_img, best_center, best_radius, method, best_radius1, ellipse_params
|
||||||
|
|
||||||
def estimate_distance(pixel_radius):
|
def estimate_distance(pixel_radius):
|
||||||
@@ -936,6 +949,51 @@ def start_save_shot_worker():
|
|||||||
logger.info("[VISION] 存图 worker 线程已启动")
|
logger.info("[VISION] 存图 worker 线程已启动")
|
||||||
|
|
||||||
|
|
||||||
|
def enqueue_save_raw_shot(frame, shot_id=None, photo_dir=None):
|
||||||
|
"""
|
||||||
|
异步保存射箭原图(无算法标注)。需 SAVE_IMAGE_ENABLED 且 SAVE_RAW_SHOT_IMAGE_ENABLED。
|
||||||
|
文件名:{photo_dir}/shot_{shot_id}_raw.jpg
|
||||||
|
"""
|
||||||
|
if not getattr(config, "SAVE_RAW_SHOT_IMAGE_ENABLED", False):
|
||||||
|
return
|
||||||
|
if not getattr(config, "SAVE_IMAGE_ENABLED", True):
|
||||||
|
return
|
||||||
|
if not shot_id:
|
||||||
|
return
|
||||||
|
if photo_dir is None:
|
||||||
|
photo_dir = config.PHOTO_DIR
|
||||||
|
|
||||||
|
try:
|
||||||
|
img_cv = image.image2cv(frame, False, False)
|
||||||
|
img_copy = np.copy(img_cv)
|
||||||
|
except Exception as e:
|
||||||
|
logger = logger_manager.logger
|
||||||
|
if logger:
|
||||||
|
logger.error(f"[VISION] enqueue_save_raw_shot 复制图像失败: {e}")
|
||||||
|
return
|
||||||
|
|
||||||
|
def _job():
|
||||||
|
try:
|
||||||
|
try:
|
||||||
|
if photo_dir not in os.listdir("/root"):
|
||||||
|
os.mkdir(photo_dir)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
filename = f"{photo_dir}/shot_{shot_id}_raw.jpg"
|
||||||
|
out = image.cv2image(img_copy, False, False)
|
||||||
|
out.save(filename)
|
||||||
|
logger = logger_manager.logger
|
||||||
|
if logger:
|
||||||
|
logger.info(f"[VISION] 已保存射箭原图: {filename}")
|
||||||
|
prune_old_images_in_dir(photo_dir, config.MAX_IMAGES, logger, "[VISION]")
|
||||||
|
except Exception as e:
|
||||||
|
logger = logger_manager.logger
|
||||||
|
if logger:
|
||||||
|
logger.error(f"[VISION] 保存射箭原图失败: {e}")
|
||||||
|
|
||||||
|
threading.Thread(target=_job, daemon=True).start()
|
||||||
|
|
||||||
|
|
||||||
def enqueue_save_shot(result_img, center, radius, method, ellipse_params,
|
def enqueue_save_shot(result_img, center, radius, method, ellipse_params,
|
||||||
laser_point, distance_m, shot_id=None, photo_dir=None,
|
laser_point, distance_m, shot_id=None, photo_dir=None,
|
||||||
yolo_roi_xyxy=None):
|
yolo_roi_xyxy=None):
|
||||||
@@ -1025,3 +1083,63 @@ def detect_target(frame, laser_point=None):
|
|||||||
logger.debug("[VISION] 使用传统黄色靶心检测")
|
logger.debug("[VISION] 使用传统黄色靶心检测")
|
||||||
return detect_circle_v3(frame, laser_point)
|
return detect_circle_v3(frame, laser_point)
|
||||||
|
|
||||||
|
|
||||||
|
def sample_target_rgb_at_physical_radius(frame, target_center, target_radius_px, radius_cm=None, angles_deg=None, patch_half_px=None, black_thresh=None, timing=False):
|
||||||
|
"""
|
||||||
|
在物方半径位置采样 RGB,判断黑/白靶。
|
||||||
|
返回: dict {ok, is_black, mean_rgb, samples, black_ratio, elapsed_ms}
|
||||||
|
"""
|
||||||
|
logger = logger_manager.logger
|
||||||
|
if target_center is None or target_radius_px is None:
|
||||||
|
return {"ok": False, "reason": "no_target", "is_black": None, "elapsed_ms": 0.0}
|
||||||
|
|
||||||
|
radius_cm = float(radius_cm if radius_cm is not None else getattr(config, "TRIANGLE_SAMPLE_RADIUS_CM", 15.0))
|
||||||
|
angles_deg = tuple(angles_deg if angles_deg is not None else getattr(config, "TRIANGLE_SAMPLE_ANGLES_DEG", (0, 90, 180, 270)))
|
||||||
|
patch_half_px = int(patch_half_px if patch_half_px is not None else getattr(config, "TRIANGLE_SAMPLE_PATCH_HALF_PX", 2))
|
||||||
|
black_thresh = float(black_thresh if black_thresh is not None else getattr(config, "TRIANGLE_SAMPLE_BLACK_THRESH", 30.0))
|
||||||
|
timing_on = bool(timing) and bool(getattr(config, "TRIANGLE_SAMPLE_TIMING_ENABLE", True))
|
||||||
|
t0 = time.perf_counter() if timing_on else None
|
||||||
|
|
||||||
|
try:
|
||||||
|
img_cv = image.image2cv(frame, False, False)
|
||||||
|
h, w = img_cv.shape[:2]
|
||||||
|
cx, cy = float(target_center[0]), float(target_center[1])
|
||||||
|
scale = float(target_radius_px) / max(radius_cm, 1e-6)
|
||||||
|
samples = []
|
||||||
|
black_count = 0
|
||||||
|
for ang in angles_deg:
|
||||||
|
rad = math.radians(float(ang))
|
||||||
|
sx = int(round(cx + math.cos(rad) * radius_cm * scale))
|
||||||
|
sy = int(round(cy + math.sin(rad) * radius_cm * scale))
|
||||||
|
x0 = max(0, sx - patch_half_px)
|
||||||
|
y0 = max(0, sy - patch_half_px)
|
||||||
|
x1 = min(w, sx + patch_half_px + 1)
|
||||||
|
y1 = min(h, sy + patch_half_px + 1)
|
||||||
|
if x1 <= x0 or y1 <= y0:
|
||||||
|
continue
|
||||||
|
patch = img_cv[y0:y1, x0:x1]
|
||||||
|
mean_rgb = patch.reshape(-1, 3).mean(axis=0)
|
||||||
|
is_black = bool(np.all(mean_rgb < black_thresh))
|
||||||
|
black_count += 1 if is_black else 0
|
||||||
|
samples.append({"angle": float(ang), "xy": (sx, sy), "mean_rgb": tuple(float(v) for v in mean_rgb), "is_black": is_black})
|
||||||
|
black_ratio = float(black_count) / float(len(samples) or 1)
|
||||||
|
out = {
|
||||||
|
"ok": len(samples) > 0,
|
||||||
|
"is_black": black_ratio >= 0.5,
|
||||||
|
"mean_rgb": tuple(float(v) for v in (np.mean([s["mean_rgb"] for s in samples], axis=0) if samples else (0, 0, 0))),
|
||||||
|
"samples": samples,
|
||||||
|
"black_ratio": black_ratio,
|
||||||
|
"elapsed_ms": (time.perf_counter() - t0) * 1000.0 if timing_on else 0.0,
|
||||||
|
}
|
||||||
|
if logger:
|
||||||
|
logger.info(
|
||||||
|
f"[TRI-SAMPLE] radius_cm={radius_cm:.1f} black_thresh={black_thresh:.1f} "
|
||||||
|
f"black_ratio={black_ratio:.2f} is_black={out['is_black']} "
|
||||||
|
f"elapsed_ms={out['elapsed_ms']:.1f} samples={len(samples)}"
|
||||||
|
)
|
||||||
|
return out
|
||||||
|
except Exception as e:
|
||||||
|
if logger:
|
||||||
|
logger.error(f"[TRI-SAMPLE] 采样失败: {e}")
|
||||||
|
return {"ok": False, "reason": str(e), "is_black": None, "elapsed_ms": 0.0}
|
||||||
|
|
||||||
|
|||||||
69
wifi.py
69
wifi.py
@@ -41,7 +41,6 @@ class WiFiManager:
|
|||||||
# WiFi 质量监测(后台线程)
|
# WiFi 质量监测(后台线程)
|
||||||
self._wifi_quality_monitor_thread = None
|
self._wifi_quality_monitor_thread = None
|
||||||
self._wifi_quality_stop_event = threading.Event()
|
self._wifi_quality_stop_event = threading.Event()
|
||||||
self._wifi_quality_lock = threading.Lock()
|
|
||||||
self._last_wifi_rtt_ms = None # 最近一次测量的 RTT
|
self._last_wifi_rtt_ms = None # 最近一次测量的 RTT
|
||||||
self._last_wifi_rssi_dbm = None # 最近一次测量的 RSSI
|
self._last_wifi_rssi_dbm = None # 最近一次测量的 RSSI
|
||||||
|
|
||||||
@@ -239,6 +238,7 @@ class WiFiManager:
|
|||||||
old_conf = _read_text(conf_path)
|
old_conf = _read_text(conf_path)
|
||||||
old_boot_ssid = _read_text(ssid_file)
|
old_boot_ssid = _read_text(ssid_file)
|
||||||
old_boot_pass = _read_text(pass_file)
|
old_boot_pass = _read_text(pass_file)
|
||||||
|
old_boot_wpa = _read_text(boot_wpa_path) if os.path.exists(boot_wpa_path) else None
|
||||||
|
|
||||||
try:
|
try:
|
||||||
try:
|
try:
|
||||||
@@ -250,13 +250,9 @@ class WiFiManager:
|
|||||||
_write_text(conf_path, full_conf)
|
_write_text(conf_path, full_conf)
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
# 删除 wpa_supplicant.conf,让 S30wifi 回退读 ssid/pass
|
_write_text(boot_wpa_path, full_conf)
|
||||||
try:
|
|
||||||
if os.path.exists(boot_wpa_path):
|
|
||||||
os.remove(boot_wpa_path)
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
|
|
||||||
|
# 仍写入 ssid/pass,便于其它脚本/人工查看;S30wifi 优先使用 wpa_supplicant.conf
|
||||||
_write_text(ssid_file, ssid.strip())
|
_write_text(ssid_file, ssid.strip())
|
||||||
_write_text(pass_file, password.strip())
|
_write_text(pass_file, password.strip())
|
||||||
|
|
||||||
@@ -296,6 +292,7 @@ class WiFiManager:
|
|||||||
if not persist:
|
if not persist:
|
||||||
# 不持久化:把 /boot 恢复成旧值(不重启,当前连接保持不变)
|
# 不持久化:把 /boot 恢复成旧值(不重启,当前连接保持不变)
|
||||||
_restore_boot(old_boot_ssid, old_boot_pass)
|
_restore_boot(old_boot_ssid, old_boot_pass)
|
||||||
|
_restore_boot_wpa(old_boot_wpa)
|
||||||
self.logger.info("[WIFI] 网络验证通过,但按 persist=False 回滚 /boot 凭证(不重启)")
|
self.logger.info("[WIFI] 网络验证通过,但按 persist=False 回滚 /boot 凭证(不重启)")
|
||||||
else:
|
else:
|
||||||
self.logger.info("[WIFI] 网络验证通过,/boot 凭证已保留(持久化)")
|
self.logger.info("[WIFI] 网络验证通过,/boot 凭证已保留(持久化)")
|
||||||
@@ -309,6 +306,7 @@ class WiFiManager:
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
# 失败:回滚 /boot 和 /etc,重启 WiFi 恢复旧网络
|
# 失败:回滚 /boot 和 /etc,重启 WiFi 恢复旧网络
|
||||||
_restore_boot(old_boot_ssid, old_boot_pass)
|
_restore_boot(old_boot_ssid, old_boot_pass)
|
||||||
|
_restore_boot_wpa(old_boot_wpa)
|
||||||
try:
|
try:
|
||||||
if old_conf is not None:
|
if old_conf is not None:
|
||||||
_write_text(conf_path, old_conf)
|
_write_text(conf_path, old_conf)
|
||||||
@@ -353,11 +351,7 @@ class WiFiManager:
|
|||||||
else:
|
else:
|
||||||
full_conf = build_sta_conf_open(ssid)
|
full_conf = build_sta_conf_open(ssid)
|
||||||
_write_text(conf_path, full_conf)
|
_write_text(conf_path, full_conf)
|
||||||
try:
|
_write_text(boot_wpa_path, full_conf)
|
||||||
if os.path.exists(boot_wpa_path):
|
|
||||||
os.remove(boot_wpa_path)
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
except ValueError as e:
|
except ValueError as e:
|
||||||
return False, str(e)
|
return False, str(e)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
@@ -548,45 +542,34 @@ class WiFiManager:
|
|||||||
network_type_callback: 获取当前网络类型的回调函数
|
network_type_callback: 获取当前网络类型的回调函数
|
||||||
on_poor_quality_callback: WiFi质量差时的回调函数
|
on_poor_quality_callback: WiFi质量差时的回调函数
|
||||||
"""
|
"""
|
||||||
with self._wifi_quality_lock:
|
if self._wifi_quality_monitor_thread is not None:
|
||||||
if self._wifi_quality_monitor_thread is not None and self._wifi_quality_monitor_thread.is_alive():
|
self.logger.warning("[WiFi Monitor] 监测线程已在运行")
|
||||||
self.logger.warning("[WiFi Monitor] 监测线程已在运行")
|
return
|
||||||
return
|
|
||||||
|
self._network_type_callback = network_type_callback
|
||||||
self._network_type_callback = network_type_callback
|
self._on_poor_quality_callback = on_poor_quality_callback
|
||||||
self._on_poor_quality_callback = on_poor_quality_callback
|
self._wifi_quality_stop_event.clear()
|
||||||
self._wifi_quality_stop_event.clear()
|
self._wifi_quality_monitor_thread = threading.Thread(
|
||||||
self._wifi_quality_monitor_thread = threading.Thread(
|
target=self._quality_monitor_loop,
|
||||||
target=self._quality_monitor_loop,
|
daemon=True,
|
||||||
daemon=True,
|
name="wifi_quality_monitor"
|
||||||
name="wifi_quality_monitor"
|
)
|
||||||
)
|
self._wifi_quality_monitor_thread.start()
|
||||||
self._wifi_quality_monitor_thread.start()
|
self.logger.info("[WiFi Monitor] 已启动后台监测线程")
|
||||||
self.logger.info("[WiFi Monitor] 已启动后台监测线程")
|
|
||||||
|
|
||||||
def stop_quality_monitor(self):
|
def stop_quality_monitor(self):
|
||||||
"""停止 WiFi 质量监测线程"""
|
"""停止 WiFi 质量监测线程"""
|
||||||
with self._wifi_quality_lock:
|
if self._wifi_quality_monitor_thread is None:
|
||||||
t = self._wifi_quality_monitor_thread
|
return
|
||||||
if t is None:
|
|
||||||
return
|
|
||||||
if not t.is_alive():
|
|
||||||
self._wifi_quality_monitor_thread = None
|
|
||||||
return
|
|
||||||
|
|
||||||
self._wifi_quality_stop_event.set()
|
self._wifi_quality_stop_event.set()
|
||||||
try:
|
try:
|
||||||
t.join(timeout=2.0)
|
self._wifi_quality_monitor_thread.join(timeout=2.0)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
self.logger.error(f"[WiFi Monitor] 停止线程失败:{e}")
|
self.logger.error(f"[WiFi Monitor] 停止线程失败:{e}")
|
||||||
|
finally:
|
||||||
with self._wifi_quality_lock:
|
self._wifi_quality_monitor_thread = None
|
||||||
if t is self._wifi_quality_monitor_thread:
|
self.logger.info("[WiFi Monitor] 已停止后台监测线程")
|
||||||
if t.is_alive():
|
|
||||||
self.logger.warning("[WiFi Monitor] 线程未在超时内退出,保留引用防止重复创建")
|
|
||||||
else:
|
|
||||||
self._wifi_quality_monitor_thread = None
|
|
||||||
self.logger.info("[WiFi Monitor] 已停止后台监测线程")
|
|
||||||
|
|
||||||
def _quality_monitor_loop(self):
|
def _quality_monitor_loop(self):
|
||||||
"""
|
"""
|
||||||
|
|||||||
267
yolo_te.py
Normal file
267
yolo_te.py
Normal file
@@ -0,0 +1,267 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
"""Standalone live camera + single YOLO runner.
|
||||||
|
|
||||||
|
不复用项目内的 `camera_manager` / `target_roi_yolo` / `config` / `logger_manager`。
|
||||||
|
|
||||||
|
功能:
|
||||||
|
- 独立初始化摄像头
|
||||||
|
- 实时读取帧
|
||||||
|
- 独立加载单个 YOLO 模型并推理
|
||||||
|
- 画出检测框、ROI、FPS
|
||||||
|
|
||||||
|
适用场景:
|
||||||
|
- 单独验证一个模型是否能跑
|
||||||
|
- 验证实时帧率
|
||||||
|
- 验证 ROI 是否裁对
|
||||||
|
- 不进入主业务射箭流程
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
|
import time
|
||||||
|
from dataclasses import dataclass
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class RunnerConfig:
|
||||||
|
camera_width: int = 640
|
||||||
|
camera_height: int = 480
|
||||||
|
model_path: str = "/root/model_278702.mud"
|
||||||
|
conf_th: float = 0.7
|
||||||
|
retry_conf_th: float = 0.5
|
||||||
|
class_ids: tuple = (0,)
|
||||||
|
merge_mode: str = "union"
|
||||||
|
coord_mode: str = "native"
|
||||||
|
roi_margin_frac: float = 0.11
|
||||||
|
min_box_side_px: int = 8
|
||||||
|
|
||||||
|
|
||||||
|
def log(msg: str):
|
||||||
|
print(msg)
|
||||||
|
|
||||||
|
|
||||||
|
class DummyLogger:
|
||||||
|
def info(self, msg):
|
||||||
|
log(msg)
|
||||||
|
|
||||||
|
def warning(self, msg):
|
||||||
|
log(msg)
|
||||||
|
|
||||||
|
def error(self, msg):
|
||||||
|
log(msg)
|
||||||
|
|
||||||
|
|
||||||
|
class StandaloneYOLORunner:
|
||||||
|
def __init__(self, cfg: RunnerConfig):
|
||||||
|
self.cfg = cfg
|
||||||
|
self.logger = DummyLogger()
|
||||||
|
self._last_fps_t = time.perf_counter()
|
||||||
|
self._frames = 0
|
||||||
|
self._fps = 0.0
|
||||||
|
self._camera = None
|
||||||
|
self._det = None
|
||||||
|
|
||||||
|
def _import_maix(self):
|
||||||
|
try:
|
||||||
|
from maix import camera, image, nn
|
||||||
|
return camera, image, nn
|
||||||
|
except Exception as e:
|
||||||
|
raise RuntimeError(f"maix import failed: {e}")
|
||||||
|
|
||||||
|
def _init_camera(self):
|
||||||
|
camera, _, _ = self._import_maix()
|
||||||
|
if self._camera is not None:
|
||||||
|
return self._camera
|
||||||
|
try:
|
||||||
|
self._camera = camera.Camera(
|
||||||
|
width=self.cfg.camera_width,
|
||||||
|
height=self.cfg.camera_height,
|
||||||
|
format=camera.RGB888,
|
||||||
|
)
|
||||||
|
except Exception:
|
||||||
|
self._camera = camera.Camera(width=self.cfg.camera_width, height=self.cfg.camera_height)
|
||||||
|
return self._camera
|
||||||
|
|
||||||
|
def _load_detector(self, model_path: str):
|
||||||
|
_, _, nn = self._import_maix()
|
||||||
|
if not model_path or not os.path.isfile(model_path):
|
||||||
|
return None
|
||||||
|
return nn.YOLOv5(model=model_path, dual_buff=False)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _get_class_id(obj):
|
||||||
|
for key in ("class_id", "cls", "label", "category", "cat_id", "id"):
|
||||||
|
if hasattr(obj, key):
|
||||||
|
v = getattr(obj, key)
|
||||||
|
if v is None:
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
return int(float(v))
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
return None
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _normalize_boxes(raw):
|
||||||
|
out = []
|
||||||
|
for o in raw or []:
|
||||||
|
if isinstance(o, (list, tuple)) and len(o) >= 6:
|
||||||
|
class Box:
|
||||||
|
pass
|
||||||
|
b = Box()
|
||||||
|
b.x, b.y, b.w, b.h, b.score, b.class_id = map(float, o[:6])
|
||||||
|
out.append(b)
|
||||||
|
else:
|
||||||
|
out.append(o)
|
||||||
|
return out
|
||||||
|
|
||||||
|
def _det_to_xyxy(self, det, obj):
|
||||||
|
x = float(getattr(obj, "x", 0.0))
|
||||||
|
y = float(getattr(obj, "y", 0.0))
|
||||||
|
w = float(getattr(obj, "w", 0.0))
|
||||||
|
h = float(getattr(obj, "h", 0.0))
|
||||||
|
return x, y, x + w, y + h
|
||||||
|
|
||||||
|
def _run_detector(self, det, img, conf_th, class_ids):
|
||||||
|
if det is None:
|
||||||
|
return []
|
||||||
|
raw = det.detect(img, conf_th=conf_th)
|
||||||
|
objs = self._normalize_boxes(raw if raw is not None else [])
|
||||||
|
out = []
|
||||||
|
for o in objs:
|
||||||
|
cid = self._get_class_id(o)
|
||||||
|
if cid is not None and cid not in class_ids:
|
||||||
|
continue
|
||||||
|
out.append(o)
|
||||||
|
return out
|
||||||
|
|
||||||
|
def _calc_fps(self):
|
||||||
|
self._frames += 1
|
||||||
|
now = time.perf_counter()
|
||||||
|
dt = now - self._last_fps_t
|
||||||
|
if dt >= 1.0:
|
||||||
|
self._fps = self._frames / dt
|
||||||
|
self._frames = 0
|
||||||
|
self._last_fps_t = now
|
||||||
|
return self._fps
|
||||||
|
|
||||||
|
def _draw_text(self, img, lines):
|
||||||
|
try:
|
||||||
|
import cv2
|
||||||
|
y = 24
|
||||||
|
for line in lines:
|
||||||
|
cv2.putText(img, line, (10, y), cv2.FONT_HERSHEY_SIMPLEX, 0.55, (0, 255, 0), 1, cv2.LINE_AA)
|
||||||
|
y += 20
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
def _clip_roi(self, x0, y0, x1, y1, w, h):
|
||||||
|
x0 = max(0, min(int(x0), w - 1))
|
||||||
|
y0 = max(0, min(int(y0), h - 1))
|
||||||
|
x1 = max(x0 + 1, min(int(x1), w))
|
||||||
|
y1 = max(y0 + 1, min(int(y1), h))
|
||||||
|
return x0, y0, x1, y1
|
||||||
|
|
||||||
|
def _merge_boxes(self, boxes):
|
||||||
|
if not boxes:
|
||||||
|
return None
|
||||||
|
x0 = min(b[0] for b in boxes)
|
||||||
|
y0 = min(b[1] for b in boxes)
|
||||||
|
x1 = max(b[2] for b in boxes)
|
||||||
|
y1 = max(b[3] for b in boxes)
|
||||||
|
return x0, y0, x1, y1
|
||||||
|
|
||||||
|
def _run_single_yolo(self, frame, img_cv):
|
||||||
|
h, w = int(img_cv.shape[0]), int(img_cv.shape[1])
|
||||||
|
if self._det is None:
|
||||||
|
self._det = self._load_detector(self.cfg.model_path)
|
||||||
|
det = self._det
|
||||||
|
if det is None:
|
||||||
|
return []
|
||||||
|
|
||||||
|
boxes = self._run_detector(det, frame, self.cfg.conf_th, self.cfg.class_ids)
|
||||||
|
if not boxes and self.cfg.retry_conf_th < self.cfg.conf_th:
|
||||||
|
boxes = self._run_detector(det, frame, self.cfg.retry_conf_th, self.cfg.class_ids)
|
||||||
|
|
||||||
|
xyxy = []
|
||||||
|
for obj in boxes:
|
||||||
|
x0, y0, x1, y1 = self._det_to_xyxy(det, obj)
|
||||||
|
if (x1 - x0) < self.cfg.min_box_side_px or (y1 - y0) < self.cfg.min_box_side_px:
|
||||||
|
continue
|
||||||
|
if self.cfg.coord_mode == "native":
|
||||||
|
x0, y0, x1, y1 = self._clip_roi(x0, y0, x1, y1, w, h)
|
||||||
|
xyxy.append((x0, y0, x1, y1))
|
||||||
|
return xyxy
|
||||||
|
|
||||||
|
def run(self):
|
||||||
|
_, image, _ = self._import_maix()
|
||||||
|
cam = self._init_camera()
|
||||||
|
log("[YOLOTE] standalone runner started")
|
||||||
|
|
||||||
|
while True:
|
||||||
|
try:
|
||||||
|
frame = cam.read()
|
||||||
|
except Exception as e:
|
||||||
|
log(f"[YOLOTE] camera read failed: {e}")
|
||||||
|
time.sleep(0.02)
|
||||||
|
continue
|
||||||
|
|
||||||
|
if frame is None:
|
||||||
|
time.sleep(0.01)
|
||||||
|
continue
|
||||||
|
|
||||||
|
try:
|
||||||
|
img_cv = image.image2cv(frame, False, False)
|
||||||
|
except Exception as e:
|
||||||
|
log(f"[YOLOTE] image2cv failed: {e}")
|
||||||
|
time.sleep(0.01)
|
||||||
|
continue
|
||||||
|
|
||||||
|
import cv2
|
||||||
|
t0 = time.perf_counter()
|
||||||
|
boxes = self._run_single_yolo(frame, img_cv)
|
||||||
|
t1 = time.perf_counter()
|
||||||
|
|
||||||
|
for i, (bx0, by0, bx1, by1) in enumerate(boxes):
|
||||||
|
cv2.rectangle(img_cv, (int(bx0), int(by0)), (int(bx1) - 1, int(by1) - 1), (0, 255, 0), 2)
|
||||||
|
cv2.putText(img_cv, f"B{i}", (int(bx0), max(0, int(by0) - 4)), cv2.FONT_HERSHEY_SIMPLEX, 0.5, (0, 255, 0), 1, cv2.LINE_AA)
|
||||||
|
|
||||||
|
fps = self._calc_fps()
|
||||||
|
self._draw_text(
|
||||||
|
img_cv,
|
||||||
|
[
|
||||||
|
f"FPS: {fps:.1f}",
|
||||||
|
f"YOLO: {(t1 - t0)*1000.0:.1f} ms",
|
||||||
|
f"Boxes: {len(boxes)}",
|
||||||
|
"Ctrl+C to exit",
|
||||||
|
],
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
frame_out = image.cv2image(img_cv, False, False)
|
||||||
|
if hasattr(cam, "show"):
|
||||||
|
cam.show(frame_out)
|
||||||
|
else:
|
||||||
|
try:
|
||||||
|
frame_out.show()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
except Exception as e:
|
||||||
|
log(f"[YOLOTE] show failed: {e}")
|
||||||
|
|
||||||
|
time.sleep(0.001)
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
cfg = RunnerConfig()
|
||||||
|
runner = StandaloneYOLORunner(cfg)
|
||||||
|
try:
|
||||||
|
runner.run()
|
||||||
|
except KeyboardInterrupt:
|
||||||
|
log("[YOLOTE] interrupted")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
Reference in New Issue
Block a user