14 Commits
Author SHA1 Message Date
linyimin 860f9c84c3 pref: 20 cm adapter 2026-06-04 15:58:07 +08:00
linyimin 1a0bfd54f7 fix: rm yolo 2026-06-04 09:00:10 +08:00
linyimin c46cf5c567 test: 2026-06-03 18:11:58 +08:00
linyimin 0d69a01a1f pref: 版本说明 2026-06-03 16:02:39 +08:00
linyimin 583748fda3 pref: 版本说明 2026-06-03 14:09:11 +08:00
linyimin d508478c73 fix: 新版本ota 2026-06-03 14:00:28 +08:00
linyimin 30c7200a7a feat: 新版本ota 2026-06-03 13:21:06 +08:00
linyimin 959635f461 feat: 新版本ota 2026-06-03 13:20:46 +08:00
linyimin 86cd8cd46e pref: 2026-06-02 18:24:18 +08:00
linyimin 26ed3c1523 pref: laser find center point 2026-06-02 16:03:18 +08:00
linyimin aa16676c74 pref: laser find center point 2026-06-02 10:32:24 +08:00
linyimin 99614fe321 pref: clean code format 2026-06-02 09:56:59 +08:00
linyimin 2ad2836d77 fix: camera change to camera_manager 2026-06-02 09:55:36 +08:00
linyimin 801453fbdb feat: 根据激光测算中心坐标 2026-06-01 22:42:55 +08:00
10 changed files with 1053 additions and 229 deletions
+3 -1
View File
@@ -1,6 +1,6 @@
id: t11
name: t11
version: 2.14.1
version: 2.15.9
author: t11
icon: ''
desc: t11
@@ -14,12 +14,14 @@ files:
- cameraParameters.xml
- config.py
- hardware.py
- laser_detector.py
- laser_manager.py
- logger_manager.py
- main.py
- model_270139.cvimodel
- model_270139.mud
- network.py
- ota_curl.sh
- ota_manager.py
- power.py
- server.pem
+2 -2
View File
@@ -134,7 +134,7 @@ IMAGE_CENTER_Y = 240 # 图像中心 Y 坐标
# ==================== 三角形四角标记:单应性偏移 + PnP 估距 ====================
# 依赖 cameraParameters.xml(相机内参)与 triangle_positions.json(四角物方坐标,厘米或毫米见 JSON 约定)。
# 部署时请把这两个文件放到 APP_DIR(与 main 同应用目录),或改下面路径为设备上的实际绝对路径。
USE_TRIANGLE_OFFSET = True # False 时仅走黄心圆/椭圆 + 半径估距,不使用三角形路径
USE_TRIANGLE_OFFSET = False # False 时仅走黄心圆/椭圆 + 半径估距,不使用三角形路径
CAMERA_CALIB_XML = APP_DIR + "/cameraParameters.xml"
TRIANGLE_POSITIONS_JSON = APP_DIR + "/triangle_positions.json"
# 检测到的三角形边长在图像中的像素范围,分辨率或靶纸占比变化时可微调
@@ -260,7 +260,7 @@ TRIANGLE_SAMPLE_RADIUS_CM = 15.0
TRIANGLE_SAMPLE_ANGLES_DEG = (0, 90, 180, 270)
TRIANGLE_SAMPLE_PATCH_HALF_PX = 2
# 开机阶段预加载 YOLO detectordetect 使用 dual_buff=False,避免返回上一帧结果。
TRIANGLE_YOLO_PRELOAD_ON_BOOT = True
TRIANGLE_YOLO_PRELOAD_ON_BOOT = False
# ── 第二段 YOLO:仅在 Stage1 裁切出的靶环图上推理(与合成 stage2 训练数据一致)→ 子框内传统算法取直角点 ──
# Stage1 靶环裁切内如何找黑三角标记(对比耗时时可切换):
+248
View File
@@ -0,0 +1,248 @@
from maix import image, time
from logger_manager import logger_manager
from camera_manager import camera_manager
_USE_CV = False
try:
import cv2
import numpy as np
_USE_CV = True
except ImportError:
pass
WIDTH = 640
HEIGHT = 480
THRESHOLD = 100
RED_RATIO = 1.5
SEARCH_RADIUS = 80
TRACK_RADIUS = 30
MIN_PIXELS = 3
COARSE_STEP = 2
STABLE_COUNT = 2
MAX_SKIP_FRAMES = 5
# Temporal smoothing
_EMA_ALPHA = 0.35
_GATE_PX = 10
_FRAME_INTERVAL_MS = 50
_prev_smoothed = None
def _red_weighted_centroid(r_ch, g_ch, b_ch, mask, x0, y0):
y_ids, x_ids = np.where(mask)
if len(y_ids) == 0:
return None
r_vals = r_ch[y_ids, x_ids].astype(np.float64)
g_vals = g_ch[y_ids, x_ids].astype(np.float64)
b_vals = b_ch[y_ids, x_ids].astype(np.float64)
w = r_vals - np.maximum(g_vals, b_vals)
w = np.clip(w, 0, None)
w = w * w
total_w = w.sum()
if total_w < 1e-6:
return None
cx = (x_ids.astype(np.float64) * w).sum() / total_w + x0
cy = (y_ids.astype(np.float64) * w).sum() / total_w + y0
return (float(cx), float(cy))
def find_ellipse(img_cv, cx, cy, roi_r, th, ratio):
x1 = max(0, cx - roi_r)
x2 = min(WIDTH, cx + roi_r)
y1 = max(0, cy - roi_r)
y2 = min(HEIGHT, cy + roi_r)
roi = img_cv[y1:y2, x1:x2]
if roi.size == 0:
return None
r = roi[:, :, 0].astype(np.int32)
g = roi[:, :, 1].astype(np.int32)
b = roi[:, :, 2].astype(np.int32)
mask = (r > th) & (r > g * ratio) & (r > b * ratio)
oe = (r > 200) & (g > 200) & (b > 200) & (r >= g) & (r >= b) & ((r - g) > 10) & ((r - b) > 10)
combined = (mask | oe).astype(np.uint8) * 255
contours, _ = cv2.findContours(combined, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE)
if not contours:
return None
largest = max(contours, key=cv2.contourArea)
if cv2.contourArea(largest) < 5:
return None
cnt = largest.copy()
for pt in cnt:
pt[0][0] += x1
pt[0][1] += y1
ellipse_valid = len(cnt) >= 5
if ellipse_valid:
(ex, ey), (ew, eh), ang = cv2.fitEllipse(cnt)
mask_ellipse = np.zeros((HEIGHT, WIDTH), dtype=np.uint8)
cv2.ellipse(mask_ellipse, (int(ex), int(ey)), (int(ew / 2), int(eh / 2)), ang, 0, 360, 255, -1)
return _red_weighted_centroid(
img_cv[:, :, 0], img_cv[:, :, 1], img_cv[:, :, 2],
mask_ellipse > 0, 0, 0
)
M = cv2.moments(cnt)
if M["m00"] > 0:
return (float(M["m10"] / M["m00"]), float(M["m01"] / M["m00"]))
return None
def is_red(r, g, b, th, ratio):
if r > th and r > g * ratio and r > b * ratio:
return True
if (r > 200 and g > 200 and b > 200 and r >= g and r >= b
and (r - g) > 10 and (r - b) > 10):
return True
return False
def find_brightest_bytes(frame, cx, cy, roi_r, th, ratio):
x1 = max(0, cx - roi_r)
x2 = min(WIDTH, cx + roi_r)
y1 = max(0, cy - roi_r)
y2 = min(HEIGHT, cy + roi_r)
data = frame.to_bytes()
best_score = 0
best_x = (x1 + x2) // 2
best_y = (y1 + y2) // 2
found_any = False
for y in range(y1, y2, COARSE_STEP):
for x in range(x1, x2, COARSE_STEP):
idx = (y * WIDTH + x) * 3
r = data[idx]
g = data[idx + 1]
b = data[idx + 2]
if is_red(r, g, b, th, ratio):
score = r + g + b
dx = x - cx
dy = y - cy
dist_decay = max(0.5, 1.0 - ((dx * dx + dy * dy) ** 0.5 / roi_r) * 0.5)
score *= dist_decay
if score > best_score:
best_score = score
best_x = x
best_y = y
found_any = True
if not found_any:
return None
sf = 4
fx1 = max(x1, best_x - sf)
fx2 = min(x2, best_x + sf + 1)
fy1 = max(y1, best_y - sf)
fy2 = min(y2, best_y + sf + 1)
sum_x = 0.0
sum_y = 0.0
total_w = 0.0
count = 0
for y in range(fy1, fy2):
for x in range(fx1, fx2):
idx = (y * WIDTH + x) * 3
r = data[idx]
g = data[idx + 1]
b = data[idx + 2]
if is_red(r, g, b, th, ratio):
w = r + g + b
sum_x += x * w
sum_y += y * w
total_w += w
count += 1
if count < MIN_PIXELS:
return (float(best_x), float(best_y))
return (float(sum_x / total_w), float(sum_y / total_w))
def _ema_filter(pos, alpha=_EMA_ALPHA):
global _prev_smoothed
if _prev_smoothed is None:
_prev_smoothed = pos
return pos
sx = alpha * pos[0] + (1 - alpha) * _prev_smoothed[0]
sy = alpha * pos[1] + (1 - alpha) * _prev_smoothed[1]
_prev_smoothed = (sx, sy)
return _prev_smoothed
def _gated(pos, gate_px=_GATE_PX):
global _prev_smoothed
if _prev_smoothed is None:
return True
dx = pos[0] - _prev_smoothed[0]
dy = pos[1] - _prev_smoothed[1]
return (dx * dx + dy * dy) <= gate_px * gate_px
def get_stable_laser_point(timeout_ms=15000, stable_count=STABLE_COUNT):
global _prev_smoothed
_prev_smoothed = None
try:
last_raw = None
stable = 0
start = time.ticks_ms()
cx, cy = WIDTH // 2, HEIGHT // 2
track_count = 0
skip_count = 0
while True:
if abs(time.ticks_diff(time.ticks_ms(), start)) > timeout_ms:
_prev_smoothed = None
return None
frame = camera_manager.read_frame()
if frame is None:
time.sleep_ms(10)
continue
if track_count > 0 and _prev_smoothed is not None:
search_cx = int(_prev_smoothed[0])
search_cy = int(_prev_smoothed[1])
search_r = TRACK_RADIUS
else:
search_cx = cx
search_cy = cy
search_r = SEARCH_RADIUS
pos_bright = find_brightest_bytes(frame, search_cx, search_cy, search_r, THRESHOLD, RED_RATIO)
pos = pos_bright
if _USE_CV:
img_cv = image.image2cv(frame, False, False)
pos_ellipse = find_ellipse(img_cv, search_cx, search_cy, search_r, THRESHOLD, RED_RATIO)
if pos_ellipse is not None:
pos = pos_ellipse
if pos is not None:
skip_count = 0
track_count += 1
filtered = _ema_filter(pos)
if last_raw is not None:
dx = abs(filtered[0] - last_raw[0])
dy = abs(filtered[1] - last_raw[1])
if dx <= 2 and dy <= 2:
stable += 1
else:
stable = 1
else:
stable = 1
last_raw = filtered
if logger_manager.logger:
logger_manager.logger.info(f"pos:{pos},filtered:{filtered},stable:{stable}")
if stable >= stable_count:
result = (int(filtered[0]), int(filtered[1]))
_prev_smoothed = None
return result
else:
skip_count += 1
if logger_manager.logger:
logger_manager.logger.info(f"find_brightest_bytes None, skip={skip_count}, track={track_count}, search_center=({search_cx},{search_cy}), search_r={search_r}")
if skip_count > MAX_SKIP_FRAMES:
_prev_smoothed = None
track_count = 0
stable = 0
last_raw = None
time.sleep_ms(_FRAME_INTERVAL_MS)
finally:
_prev_smoothed = None
+39 -20
View File
@@ -54,8 +54,8 @@ class LaserManager:
@property
def laser_point(self):
"""当前激光点(如果启用硬编码,则返回硬编码值)"""
if config.HARDCODE_LASER_POINT:
return config.HARDCODE_LASER_POINT_VALUE
# if config.HARDCODE_LASER_POINT:
# return config.HARDCODE_LASER_POINT_VALUE
return self._laser_point
def get_last_frame_with_ellipse(self):
@@ -102,31 +102,28 @@ class LaserManager:
# ==================== 业务方法 ====================
def load_laser_point(self):
"""从配置文件加载激光中心点,失败则使用默认值
如果启用硬编码模式,则直接使用硬编码值
"""
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
# 正常模式:从配置文件加载
"""加载激光中心点:优先使用本地保存的坐标,其次硬编码值,最后默认值"""
# 优先:从本地持久化文件加载(由 cmd 201 保存)
try:
if "laser_config.json" in os.listdir("/root"):
with open(config.CONFIG_FILE, "r") as f:
data = json.load(f)
if isinstance(data, list) and len(data) == 2:
self._laser_point = (int(data[0]), int(data[1]))
self.logger.debug(f"[INFO] 加载激光点: {self._laser_point}")
self.logger.info(f"[LASER] 从本地加载激光点: {self._laser_point}")
return self._laser_point
else:
raise ValueError
else:
self._laser_point = config.DEFAULT_LASER_POINT
except:
self._laser_point = config.DEFAULT_LASER_POINT
except Exception:
pass
# 其次:硬编码值
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
# 最后:默认值
self._laser_point = config.DEFAULT_LASER_POINT
self.logger.info(f"[LASER] 使用默认激光点: {self._laser_point}")
return self._laser_point
def save_laser_point(self, point):
@@ -1264,6 +1261,28 @@ class LaserManager:
except Exception as 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()
+343 -175
View File
File diff suppressed because it is too large Load Diff
+57
View File
@@ -0,0 +1,57 @@
#!/bin/sh
# OTA 更新脚本 - 使用 curl 断点下载
# 用法: sh ota_curl.sh <下载URL>
# 示例: sh ota_curl.sh http://example.com/maix-t11-v2.15.1.zip
set -e
APP_DIR="/maixapp/apps/t11"
BACKUP_BASE="$APP_DIR/backups"
TMP_DIR="/tmp/ota_curl"
PENDING_FILE="$APP_DIR/ota_pending.json"
if [ $# -lt 1 ]; then
echo "用法: $0 <下载URL>"
exit 1
fi
OTA_URL="$1"
FILENAME=$(basename "$OTA_URL" | sed 's/?.*//')
[ -z "$FILENAME" ] && FILENAME="update.zip"
mkdir -p "$TMP_DIR" "$BACKUP_BASE"
# 1. 断点下载
echo "[OTA] 开始下载: $OTA_URL"
echo "[OTA] 保存到: $TMP_DIR/$FILENAME"
curl -C - -L --retry 3 --retry-delay 5 -o "$TMP_DIR/$FILENAME" "$OTA_URL"
echo "[OTA] 下载完成"
# 2. 备份当前目录
TIMESTAMP=$(date +%Y%m%d_%H%M%S 2>/dev/null || echo "00000000_000000")
BACKUP_DIR="$BACKUP_BASE/backup_$TIMESTAMP"
mkdir -p "$BACKUP_DIR"
echo "[OTA] 备份到: $BACKUP_DIR"
for f in "$APP_DIR"/*.py "$APP_DIR"/*.json "$APP_DIR"/*.xml "$APP_DIR"/*.yaml "$APP_DIR"/*.pem "$APP_DIR"/*.mud "$APP_DIR"/*.so "$APP_DIR"/S99archery; do
[ -f "$f" ] && cp "$f" "$BACKUP_DIR/"
done
echo "[OTA] 备份完成"
# 3. 解压并替换文件
echo "[OTA] 开始更新..."
if echo "$FILENAME" | grep -qi '\.zip$'; then
unzip -q -o "$TMP_DIR/$FILENAME" -d "$APP_DIR/"
else
cp "$TMP_DIR/$FILENAME" "$APP_DIR/"
fi
sync
# 4. 写入 pending 文件(用于崩溃恢复)
echo '{"ts":0,"url":"'"$OTA_URL"'","backup_dir":"'"$BACKUP_DIR"'","restart_count":0,"max_restarts":3}' > "$PENDING_FILE"
sync
echo "[OTA] 更新完成,准备重启..."
# 5. 重启
sleep 1
reboot
+330
View File
@@ -0,0 +1,330 @@
#!/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)
+22
View File
@@ -0,0 +1,22 @@
# 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
+1 -23
View File
@@ -4,28 +4,6 @@
应用版本号
每次 OTA 更新时,只需要更新这个文件中的版本号
"""
VERSION = '2.14.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登录部分
VERSION = '2.15.9'
+8 -8
View File
@@ -535,7 +535,7 @@ def detect_circle_v3(frame, laser_point=None, img_cv=None):
logger.debug(f"[detect_circle_v3] begin {datetime.now()}")
# -- 1. 缩图加速(与三角形路径保持一致)
h_orig, w_orig = img_cv.shape[:2]
MAX_DET_DIM = 320
MAX_DET_DIM = 480
long_side = max(h_orig, w_orig)
if long_side > MAX_DET_DIM:
det_scale = MAX_DET_DIM / long_side
@@ -570,8 +570,8 @@ def detect_circle_v3(frame, laser_point=None, img_cv=None):
# -- 3. 红色掩码:在循环外只算一次
mask_red = cv2.bitwise_or(
cv2.inRange(hsv, np.array([0, 80, 0]), np.array([10, 255, 255])),
cv2.inRange(hsv, np.array([170, 80, 0]), np.array([180, 255, 255])),
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)
@@ -580,10 +580,10 @@ def detect_circle_v3(frame, laser_point=None, img_cv=None):
red_candidates = []
for cnt_r in contours_red:
ar = cv2.contourArea(cnt_r)
if ar <= 50:
if ar <= 10:
continue
pr = cv2.arcLength(cnt_r, True)
if pr <= 0 or (4 * np.pi * ar) / (pr * pr) <= 0.6:
if pr <= 0 or (4 * np.pi * ar) / (pr * pr) <= 0.3:
continue
if len(cnt_r) >= 5:
(xr, yr), (wr, hr), _ = cv2.fitEllipse(cnt_r)
@@ -599,13 +599,13 @@ def detect_circle_v3(frame, laser_point=None, img_cv=None):
valid_targets = []
for cnt_yellow in contours_yellow:
area = cv2.contourArea(cnt_yellow)
if area <= 50:
if area <= 15:
continue
perimeter = cv2.arcLength(cnt_yellow, True)
if perimeter <= 0:
continue
circularity = (4 * np.pi * area) / (perimeter * perimeter)
if circularity <= 0.7:
if circularity <= 0.5:
continue
if logger:
logger.info(f"[target] -> 面积:{area:.1f}, 圆度:{circularity:.2f}")
@@ -625,7 +625,7 @@ def detect_circle_v3(frame, laser_point=None, img_cv=None):
ddx = yellow_center[0] - rc["center"][0]
ddy = yellow_center[1] - rc["center"][1]
dist_centers = math.hypot(ddx, ddy)
if dist_centers < yellow_radius * 1.5 and rc["radius"] > yellow_radius * 0.8:
if dist_centers < yellow_radius * 1.5 and rc["radius"] > yellow_radius * 0.7:
if logger:
logger.info(f"[target] -> 找到匹配的红圈: 黄心({yellow_center}), "
f"红心({rc['center']}), 距离:{dist_centers:.1f}, "