Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
165eeff64e | ||
|
|
a184ff7d55 | ||
|
|
054e9e6d90 | ||
|
|
ae339889c2 | ||
|
|
b94b0f2e55 | ||
|
|
e82941a161 | ||
|
|
6556cfcf74 | ||
|
|
abbf30d7c0 | ||
|
|
5cf752bb3f | ||
|
|
6d8de56bfa | ||
|
|
aee1a92760 | ||
|
|
c34efed6f9 | ||
|
|
226394d3ed | ||
|
|
b169618b16 | ||
|
|
5ab4ef2944 | ||
|
|
577ff02c04 | ||
|
|
82d0008257 | ||
|
|
373eeb786a | ||
|
|
4500e62647 | ||
|
|
49a84e80e1 | ||
|
|
9654b79cec | ||
|
|
1ea8c64a40 | ||
|
|
9dd6fef6f8 | ||
|
|
860f9c84c3 | ||
|
|
1a0bfd54f7 | ||
|
|
c46cf5c567 | ||
|
|
0d69a01a1f | ||
|
|
583748fda3 | ||
|
|
d508478c73 | ||
|
|
30c7200a7a | ||
|
|
959635f461 | ||
|
|
86cd8cd46e | ||
|
|
26ed3c1523 | ||
|
|
aa16676c74 | ||
|
|
99614fe321 | ||
|
|
2ad2836d77 | ||
|
|
801453fbdb |
@@ -0,0 +1 @@
|
||||
*.sh text eol=lf
|
||||
@@ -1,3 +1,4 @@
|
||||
/cpp_ext/build/
|
||||
/.cursor/
|
||||
/dist/
|
||||
.idea
|
||||
Generated
+8
@@ -0,0 +1,8 @@
|
||||
# 默认忽略的文件
|
||||
/shelf/
|
||||
/workspace.xml
|
||||
# 基于编辑器的 HTTP 客户端请求
|
||||
/httpRequests/
|
||||
# Datasource local storage ignored files
|
||||
/dataSources/
|
||||
/dataSources.local.xml
|
||||
Generated
+12
@@ -0,0 +1,12 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<module type="PYTHON_MODULE" version="4">
|
||||
<component name="NewModuleRootManager">
|
||||
<content url="file://$MODULE_DIR$" />
|
||||
<orderEntry type="jdk" jdkName="yolov8" jdkType="Python SDK" />
|
||||
<orderEntry type="sourceFolder" forTests="false" />
|
||||
</component>
|
||||
<component name="PyDocumentationSettings">
|
||||
<option name="format" value="PLAIN" />
|
||||
<option name="myDocStringFormat" value="Plain" />
|
||||
</component>
|
||||
</module>
|
||||
+6
@@ -0,0 +1,6 @@
|
||||
<component name="InspectionProjectProfileManager">
|
||||
<settings>
|
||||
<option name="USE_PROJECT_PROFILE" value="false" />
|
||||
<version value="1.0" />
|
||||
</settings>
|
||||
</component>
|
||||
Generated
+7
@@ -0,0 +1,7 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project version="4">
|
||||
<component name="Black">
|
||||
<option name="sdkName" value="yolov8" />
|
||||
</component>
|
||||
<component name="ProjectRootManager" version="2" project-jdk-name="yolov8" project-jdk-type="Python SDK" />
|
||||
</project>
|
||||
Generated
+8
@@ -0,0 +1,8 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project version="4">
|
||||
<component name="ProjectModuleManager">
|
||||
<modules>
|
||||
<module fileurl="file://$PROJECT_DIR$/.idea/archery.iml" filepath="$PROJECT_DIR$/.idea/archery.iml" />
|
||||
</modules>
|
||||
</component>
|
||||
</project>
|
||||
Generated
+6
@@ -0,0 +1,6 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project version="4">
|
||||
<component name="VcsDirectoryMappings">
|
||||
<mapping directory="" vcs="Git" />
|
||||
</component>
|
||||
</project>
|
||||
Vendored
+3
@@ -0,0 +1,3 @@
|
||||
{
|
||||
"cmake.sourceDirectory": "E:/code/code/code/new/new/new/archery/cpp_ext"
|
||||
}
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -1,6 +1,6 @@
|
||||
id: t11
|
||||
name: t11
|
||||
version: 2.14.1
|
||||
version: 2.15.31
|
||||
author: t11
|
||||
icon: ''
|
||||
desc: t11
|
||||
@@ -12,14 +12,15 @@ files:
|
||||
- at_client.py
|
||||
- camera_manager.py
|
||||
- cameraParameters.xml
|
||||
- charging_exit.sh
|
||||
- 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
|
||||
|
||||
+7
-6
@@ -76,10 +76,11 @@ class ATClient:
|
||||
"""
|
||||
expect_b = expect.encode() if isinstance(expect, str) else expect
|
||||
with self._cmd_lock:
|
||||
# 初始化等待
|
||||
self._waiting = True
|
||||
self._expect = expect_b
|
||||
self._resp = b""
|
||||
with self._q_lock:
|
||||
# 初始化等待
|
||||
self._waiting = True
|
||||
self._expect = expect_b
|
||||
self._resp = b""
|
||||
|
||||
# 发送
|
||||
if cmd:
|
||||
@@ -300,8 +301,8 @@ class ATClient:
|
||||
if len(self._rx) > 512 * 1024:
|
||||
self._rx = self._rx[-256 * 1024:]
|
||||
else:
|
||||
if len(self._rx) > 16384:
|
||||
self._rx = self._rx[-4096:]
|
||||
if len(self._rx) > 32768:
|
||||
self._rx = self._rx[-16384:]
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
#!/bin/sh
|
||||
|
||||
# The application supplies its own PID. Refuse broad or malformed targets.
|
||||
TARGET_PID="$1"
|
||||
LASER_DEVICE="${2:-/dev/ttyS1}"
|
||||
LASER_BAUD="${3:-9600}"
|
||||
|
||||
turn_off_laser() {
|
||||
if [ ! -c "$LASER_DEVICE" ]; then
|
||||
echo "[CHARGE] laser serial device not found: $LASER_DEVICE" >&2
|
||||
return 1
|
||||
fi
|
||||
|
||||
stty -F "$LASER_DEVICE" "$LASER_BAUD" raw -echo 2>/dev/null || return 1
|
||||
printf '\252\000\001\276\000\001\000\000\300' > "$LASER_DEVICE"
|
||||
}
|
||||
|
||||
case "$TARGET_PID" in
|
||||
''|*[!0-9]*)
|
||||
echo "[CHARGE] invalid application pid: $TARGET_PID" >&2
|
||||
exit 2
|
||||
;;
|
||||
esac
|
||||
|
||||
if [ "$TARGET_PID" -le 1 ]; then
|
||||
echo "[CHARGE] refusing to terminate pid: $TARGET_PID" >&2
|
||||
exit 2
|
||||
fi
|
||||
|
||||
# First request laser-off while the application still owns the initialized UART.
|
||||
turn_off_laser || true
|
||||
|
||||
kill -TERM "$TARGET_PID" 2>/dev/null || true
|
||||
|
||||
# Wait up to two seconds for a graceful exit, then force termination.
|
||||
WAIT_COUNT=0
|
||||
while kill -0 "$TARGET_PID" 2>/dev/null && [ "$WAIT_COUNT" -lt 20 ]; do
|
||||
sleep 0.1
|
||||
WAIT_COUNT=$((WAIT_COUNT + 1))
|
||||
done
|
||||
if kill -0 "$TARGET_PID" 2>/dev/null; then
|
||||
kill -KILL "$TARGET_PID" 2>/dev/null || true
|
||||
sleep 0.1
|
||||
fi
|
||||
|
||||
# Send laser-off again after the application releases the UART.
|
||||
turn_off_laser || true
|
||||
@@ -24,7 +24,7 @@ TRIANGLE_DETECT_SCALE = 0.4
|
||||
# SERVER_IP = "stcp.shelingxingqiu.com"
|
||||
SERVER_IP = "www.shelingxingqiu.com"
|
||||
SERVER_PORT = 50005
|
||||
HEARTBEAT_INTERVAL = 15 # 心跳间隔(秒)
|
||||
HEARTBEAT_INTERVAL = 5 # 心跳间隔(秒)
|
||||
|
||||
# WiFi 质量评估(开机先尝试 WiFi;质量差且 4G 可用则切到 4G,本次上电直至关机锁定 4G)
|
||||
WIFI_QUALITY_RTT_SAMPLES = 3 # 到业务服务器 TCP 建连耗时采样次数,取中位数
|
||||
@@ -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 detector;detect 使用 dual_buff=False,避免返回上一帧结果。
|
||||
TRIANGLE_YOLO_PRELOAD_ON_BOOT = True
|
||||
TRIANGLE_YOLO_PRELOAD_ON_BOOT = False
|
||||
|
||||
# ── 第二段 YOLO:仅在 Stage1 裁切出的靶环图上推理(与合成 stage2 训练数据一致)→ 子框内传统算法取直角点 ──
|
||||
# Stage1 靶环裁切内如何找黑三角标记(对比耗时时可切换):
|
||||
@@ -308,8 +308,15 @@ LASER_COLOR = (0, 255, 0) # RGB颜色
|
||||
LASER_THICKNESS = 1
|
||||
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 = True # 是否保存图像(True=保存,False=不保存)
|
||||
SAVE_IMAGE_ENABLED = False # 是否保存图像(True=保存,False=不保存)
|
||||
PHOTO_DIR = "/root/phot" # 照片存储目录
|
||||
MAX_IMAGES = 1000
|
||||
# Stage2 调试目录(默认 PHOTO_DIR/stage2_roi)内 JPEG 最多保留张数;None 表示与 MAX_IMAGES 相同
|
||||
@@ -336,6 +343,15 @@ PIN_MAPPINGS = {
|
||||
# ==================== 电源配置 ====================
|
||||
AUTO_POWER_OFF_IN_SECONDS = 10 * 60 # 自动关机时间(秒),0表示不自动关机
|
||||
|
||||
# 实机数据:正常放电约为正电流,插入充电线后约为负电流。
|
||||
CHARGING_SHUTDOWN_ENABLED = True # True=充电时退出应用,False=关闭充电关机功能
|
||||
CHARGING_DIAGNOSTIC_LOG_ENABLED = False
|
||||
CHARGING_CHECK_INTERVAL_MS = 5000
|
||||
CHARGING_CURRENT_THRESHOLD_MA = 100.0
|
||||
CHARGING_CONFIRM_COUNT = 2
|
||||
CHARGING_NOTIFY_TIMEOUT_MS = 30000
|
||||
CHARGING_EXIT_SCRIPT = APP_DIR + "/charging_exit.sh"
|
||||
|
||||
BATTERY_SOC_LPF_ALPHA = 0.5
|
||||
BATTERY_SOC_AVG_WINDOW = 5
|
||||
|
||||
|
||||
@@ -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
@@ -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()
|
||||
|
||||
+2
-2
@@ -65,8 +65,8 @@ class LoggerManager:
|
||||
backup_count = config.LOG_BACKUP_COUNT
|
||||
|
||||
try:
|
||||
# 创建日志队列(无界队列)
|
||||
self._log_queue = queue.Queue(-1)
|
||||
# 创建日志队列(有界队列,防止内存泄漏;满时自动丢弃旧日志)
|
||||
self._log_queue = queue.Queue(maxsize=config.LOG_QUEUE_MAXSIZE)
|
||||
|
||||
# 确保日志文件所在的目录存在
|
||||
log_dir = os.path.dirname(log_file)
|
||||
|
||||
@@ -22,7 +22,7 @@ from version import VERSION
|
||||
# from logger import init_logging, get_logger, stop_logging
|
||||
from logger_manager import logger_manager
|
||||
from time_sync import sync_system_time_from_4g
|
||||
from power import init_ina226
|
||||
from power import charging_shutdown_monitor, init_ina226
|
||||
from laser_manager import laser_manager
|
||||
from vision import start_save_shot_worker
|
||||
from network import network_manager
|
||||
@@ -122,9 +122,16 @@ def cmd_str():
|
||||
|
||||
# 1. 初始化日志系统
|
||||
import logging
|
||||
logger_manager.init_logging(log_level=logging.DEBUG)
|
||||
logger_manager.init_logging(log_level=logging.WARNING)
|
||||
logger = logger_manager.logger
|
||||
|
||||
# 充电关机独立读取 INA226,不依赖 TCP 连接或心跳流程。
|
||||
try:
|
||||
_thread.start_new_thread(charging_shutdown_monitor, ())
|
||||
except Exception as e:
|
||||
if logger:
|
||||
logger.error(f"[CHARGE] 启动独立监测线程失败: {e}")
|
||||
|
||||
# 补充:因为初始化的时候,激光会亮,先关了它
|
||||
# laser_manager.turn_off_laser()
|
||||
|
||||
@@ -283,41 +290,34 @@ def cmd_str():
|
||||
|
||||
pressure_buf = []
|
||||
pressure_sum = 0
|
||||
pressure_abs_sum = 0
|
||||
pressure_min = 4095
|
||||
pressure_max = 0
|
||||
pressure_t0_ms = None
|
||||
last_avg_abs = 0
|
||||
|
||||
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
|
||||
if not pressure_buf:
|
||||
return
|
||||
t1_ms = time.ticks_ms()
|
||||
n = len(pressure_buf)
|
||||
avg = (pressure_sum / n) if n else 0
|
||||
avg_abs = (pressure_abs_sum / n) if n else 0
|
||||
# 一行输出:方便后处理画曲线;同时带上统计信息便于快速看波峰
|
||||
line = (
|
||||
f"[气压批量] reason={reason} "
|
||||
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"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}"
|
||||
)
|
||||
if logger:
|
||||
logger.debug(line)
|
||||
else:
|
||||
print(line)
|
||||
if config.AIR_PRESSURE_lOG:
|
||||
t1_ms = time.ticks_ms()
|
||||
n = len(pressure_buf)
|
||||
avg = (pressure_sum / n) if n else 0
|
||||
line = (
|
||||
f"[气压批量] reason={reason} "
|
||||
f"t0={pressure_t0_ms} t1={t1_ms} n={n} "
|
||||
f"min={pressure_min} max={pressure_max} avg={avg:.1f} "
|
||||
f"values={','.join(map(str, pressure_buf))}"
|
||||
)
|
||||
if logger:
|
||||
logger.debug(line)
|
||||
else:
|
||||
print(line)
|
||||
# 无论是否记录日志,都必须清空 buffer,否则内存泄漏
|
||||
pressure_buf = []
|
||||
pressure_sum = 0
|
||||
pressure_abs_sum = 0
|
||||
pressure_min = 4095
|
||||
pressure_max = 0
|
||||
pressure_t0_ms = None
|
||||
last_avg_abs = avg_abs
|
||||
|
||||
# 主循环:检测扳机触发 → 拍照 → 分析 → 上报
|
||||
while not app.need_exit():
|
||||
@@ -336,6 +336,7 @@ def cmd_str():
|
||||
time.sleep_ms(250)
|
||||
continue
|
||||
|
||||
# todo 去除或者不在这里检测
|
||||
# 不在 OTA 状态下,检测是否空闲足够长,自动关机
|
||||
# print(f"[MAIN] 空闲时间: {hardware_manager.get_idle_time_in_sec() }秒")
|
||||
# print(f"配置关机时间:{config.AUTO_POWER_OFF_IN_SECONDS} 秒")
|
||||
@@ -352,12 +353,10 @@ def cmd_str():
|
||||
if network_manager.manual_trigger_flag:
|
||||
network_manager.clear_manual_trigger()
|
||||
adc_val = config.ADC_TRIGGER_THRESHOLD + 1
|
||||
adc_abs_val = 10
|
||||
if logger:
|
||||
logger.info("[TEST] TCP命令触发射箭")
|
||||
else:
|
||||
adc_val = hardware_manager.adc_obj.read()
|
||||
adc_abs_val = hardware_manager.adc_obj.read_vol()
|
||||
except Exception as e:
|
||||
logger = logger_manager.logger
|
||||
if logger:
|
||||
@@ -368,9 +367,8 @@ def cmd_str():
|
||||
# ====== 气压采样缓存(每次循环都记录,批量输出日志)======
|
||||
if pressure_t0_ms is None:
|
||||
pressure_t0_ms = current_time
|
||||
pressure_buf.append((adc_val, adc_abs_val))
|
||||
pressure_buf.append(adc_val)
|
||||
pressure_sum += adc_val
|
||||
pressure_abs_sum += adc_abs_val
|
||||
if adc_val < pressure_min:
|
||||
pressure_min = adc_val
|
||||
if adc_val > pressure_max:
|
||||
|
||||
+509
-213
File diff suppressed because it is too large
Load Diff
+57
@@ -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
|
||||
@@ -5,6 +5,8 @@
|
||||
提供电压、电流监测和充电状态检测
|
||||
"""
|
||||
import config
|
||||
import os
|
||||
import subprocess
|
||||
from logger_manager import logger_manager
|
||||
from maix import time as maix_time
|
||||
|
||||
@@ -85,7 +87,7 @@ def get_bus_voltage():
|
||||
def get_current():
|
||||
"""
|
||||
读取电流(单位:mA)
|
||||
正数表示充电,负数表示放电
|
||||
当前电源板实测:正数表示放电,负数表示充电。
|
||||
|
||||
INA226 电流计算公式:
|
||||
Current = (Current Register Value) × Current_LSB
|
||||
@@ -96,13 +98,13 @@ def get_current():
|
||||
return 0.0
|
||||
raw = read_register(config.REG_CURRENT)
|
||||
# INA226 电流寄存器是16位有符号整数
|
||||
# 最高位是符号位:0=正(充电),1=负(放电)
|
||||
# 最高位是符号位;电流方向含义取决于电源板的采样电阻接线方向。
|
||||
# 计算 Current_LSB(根据 CALIBRATION_VALUE)
|
||||
current_lsb = 0.001 * config.CALIBRATION_VALUE / 4096 # 单位:A
|
||||
# 处理有符号数:如果最高位为1,转换为负数
|
||||
if raw & 0x8000: # 最高位为1,表示负数(放电)
|
||||
if raw & 0x8000:
|
||||
signed_raw = raw - 0x10000 # 转换为有符号整数
|
||||
else: # 最高位为0,表示正数(充电)
|
||||
else:
|
||||
signed_raw = raw
|
||||
# 转换为毫安
|
||||
current_ma = signed_raw * current_lsb * 1000
|
||||
@@ -129,7 +131,7 @@ def is_charging(threshold_ma=10.0):
|
||||
"""
|
||||
try:
|
||||
current = get_current()
|
||||
is_charge = current > threshold_ma
|
||||
is_charge = current < -abs(float(threshold_ma))
|
||||
return is_charge
|
||||
except Exception as e:
|
||||
logger = logger_manager.logger
|
||||
@@ -140,6 +142,115 @@ def is_charging(threshold_ma=10.0):
|
||||
return False
|
||||
|
||||
|
||||
def charging_shutdown_monitor():
|
||||
"""独立监测 INA226;连续确认充电后通知服务器并退出应用。"""
|
||||
logger = logger_manager.logger
|
||||
shutdown_enabled = bool(getattr(config, "CHARGING_SHUTDOWN_ENABLED", False))
|
||||
diagnostic_enabled = bool(getattr(config, "CHARGING_DIAGNOSTIC_LOG_ENABLED", False))
|
||||
if not shutdown_enabled and not diagnostic_enabled:
|
||||
if logger:
|
||||
logger.info("[CHARGE] 充电退出监测已禁用")
|
||||
return
|
||||
|
||||
interval_ms = max(100, int(getattr(config, "CHARGING_CHECK_INTERVAL_MS", 5000)))
|
||||
threshold_ma = float(getattr(config, "CHARGING_CURRENT_THRESHOLD_MA", 10.0))
|
||||
confirm_required = max(1, int(getattr(config, "CHARGING_CONFIRM_COUNT", 2)))
|
||||
confirm_count = 0
|
||||
|
||||
if logger:
|
||||
logger.info(
|
||||
f"[CHARGE] 独立监测线程启动: interval={interval_ms}ms, "
|
||||
f"threshold={threshold_ma:.1f}mA, confirm={confirm_required}, "
|
||||
f"shutdown={'on' if shutdown_enabled else 'off'}"
|
||||
)
|
||||
|
||||
while True:
|
||||
current_ma = get_current()
|
||||
if diagnostic_enabled and logger:
|
||||
voltage = get_bus_voltage()
|
||||
logger.info(
|
||||
f"[CHARGE-DIAG] INA226 voltage={voltage:.3f}V, "
|
||||
f"current={current_ma:.1f}mA"
|
||||
)
|
||||
|
||||
if not shutdown_enabled:
|
||||
maix_time.sleep_ms(interval_ms)
|
||||
continue
|
||||
|
||||
if current_ma < -abs(threshold_ma):
|
||||
confirm_count += 1
|
||||
if logger:
|
||||
logger.info(
|
||||
f"[CHARGE] INA226 充电电流 {current_ma:.1f}mA "
|
||||
f"({confirm_count}/{confirm_required})"
|
||||
)
|
||||
else:
|
||||
confirm_count = 0
|
||||
|
||||
if confirm_count >= confirm_required:
|
||||
script_path = getattr(
|
||||
config,
|
||||
"CHARGING_EXIT_SCRIPT",
|
||||
config.APP_DIR + "/charging_exit.sh",
|
||||
)
|
||||
if not os.path.isfile(script_path):
|
||||
if logger:
|
||||
logger.error(f"[CHARGE] 退出脚本不存在: {script_path}")
|
||||
confirm_count = 0
|
||||
else:
|
||||
if logger:
|
||||
logger.warning(
|
||||
f"[CHARGE] 已连续确认充电,通知服务器后退出应用: current={current_ma:.1f}mA"
|
||||
)
|
||||
try:
|
||||
from network import network_manager
|
||||
|
||||
notify_timeout_ms = max(
|
||||
0,
|
||||
int(getattr(config, "CHARGING_NOTIFY_TIMEOUT_MS", 30000)),
|
||||
)
|
||||
notification_sent = network_manager.safe_enqueue_and_wait(
|
||||
{"poweroff": "充电中"},
|
||||
2,
|
||||
high=True,
|
||||
timeout_ms=notify_timeout_ms,
|
||||
)
|
||||
if notification_sent:
|
||||
if logger:
|
||||
logger.info("[CHARGE] 充电状态已发送到服务器")
|
||||
elif logger:
|
||||
logger.warning(
|
||||
f"[CHARGE] 等待服务器发送超时({notify_timeout_ms}ms),继续执行退出"
|
||||
)
|
||||
except Exception as e:
|
||||
if logger:
|
||||
logger.error(f"[CHARGE] 充电状态上报失败,继续执行退出: {e}")
|
||||
try:
|
||||
from laser_manager import laser_manager
|
||||
|
||||
laser_manager.turn_off_laser()
|
||||
if logger:
|
||||
logger.info("[CHARGE] 激光关闭命令已发送")
|
||||
except Exception as e:
|
||||
if logger:
|
||||
logger.error(f"[CHARGE] Python 关闭激光失败,交由退出脚本兜底: {e}")
|
||||
try:
|
||||
subprocess.Popen([
|
||||
"/bin/sh",
|
||||
script_path,
|
||||
str(os.getpid()),
|
||||
str(getattr(config, "DISTANCE_SERIAL_DEVICE", "/dev/ttyS1")),
|
||||
str(getattr(config, "DISTANCE_SERIAL_BAUDRATE", 9600)),
|
||||
])
|
||||
return
|
||||
except Exception as e:
|
||||
if logger:
|
||||
logger.error(f"[CHARGE] 调用退出脚本失败: {e}")
|
||||
confirm_count = 0
|
||||
|
||||
maix_time.sleep_ms(interval_ms)
|
||||
|
||||
|
||||
def voltage_to_percent(voltage):
|
||||
"""
|
||||
根据电压估算电池百分比(高密度查表插值 + 滤波)。
|
||||
|
||||
+1
-1
@@ -320,8 +320,8 @@ def process_shot(adc_val):
|
||||
logger = logger_manager.logger
|
||||
|
||||
try:
|
||||
network_manager.safe_enqueue({"shoot_event": "start"}, msg_type=2, high=True)
|
||||
frame = camera_manager.read_frame()
|
||||
network_manager.safe_enqueue({"shoot_event": "start"}, msg_type=2, high=True)
|
||||
|
||||
# 调用算法分析
|
||||
analysis_result = analyze_shot(frame)
|
||||
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,144 @@
|
||||
import importlib.util
|
||||
from pathlib import Path
|
||||
import sys
|
||||
import types
|
||||
import unittest
|
||||
from unittest import mock
|
||||
|
||||
|
||||
class _StopMonitor(Exception):
|
||||
pass
|
||||
|
||||
|
||||
class _FakeTime:
|
||||
now_ms = 0
|
||||
stop_at_ms = None
|
||||
|
||||
@classmethod
|
||||
def reset(cls, stop_at_ms=None):
|
||||
cls.now_ms = 0
|
||||
cls.stop_at_ms = stop_at_ms
|
||||
|
||||
@classmethod
|
||||
def ticks_ms(cls):
|
||||
return cls.now_ms
|
||||
|
||||
@classmethod
|
||||
def sleep_ms(cls, milliseconds):
|
||||
cls.now_ms += milliseconds
|
||||
if cls.stop_at_ms is not None and cls.now_ms >= cls.stop_at_ms:
|
||||
raise _StopMonitor()
|
||||
|
||||
|
||||
def _load_power_module():
|
||||
module_path = Path(__file__).resolve().parents[1] / "power.py"
|
||||
module_name = "power_charging_shutdown_test"
|
||||
maix_module = types.ModuleType("maix")
|
||||
maix_module.time = _FakeTime
|
||||
|
||||
previous_maix = sys.modules.get("maix")
|
||||
sys.modules["maix"] = maix_module
|
||||
try:
|
||||
spec = importlib.util.spec_from_file_location(module_name, module_path)
|
||||
module = importlib.util.module_from_spec(spec)
|
||||
spec.loader.exec_module(module)
|
||||
return module
|
||||
finally:
|
||||
if previous_maix is None:
|
||||
sys.modules.pop("maix", None)
|
||||
else:
|
||||
sys.modules["maix"] = previous_maix
|
||||
|
||||
|
||||
power = _load_power_module()
|
||||
|
||||
|
||||
class ChargingShutdownTests(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.config_patch = mock.patch.multiple(
|
||||
power.config,
|
||||
CHARGING_SHUTDOWN_ENABLED=True,
|
||||
CHARGING_DIAGNOSTIC_LOG_ENABLED=False,
|
||||
CHARGING_CHECK_INTERVAL_MS=5000,
|
||||
CHARGING_CURRENT_THRESHOLD_MA=100.0,
|
||||
CHARGING_CONFIRM_COUNT=2,
|
||||
CHARGING_NOTIFY_TIMEOUT_MS=30000,
|
||||
CHARGING_EXIT_SCRIPT="/tmp/charging_exit.sh",
|
||||
)
|
||||
self.config_patch.start()
|
||||
self.network_manager = mock.Mock()
|
||||
self.network_manager.safe_enqueue_and_wait.return_value = True
|
||||
network_module = types.ModuleType("network")
|
||||
network_module.network_manager = self.network_manager
|
||||
self.network_module_patch = mock.patch.dict(
|
||||
sys.modules,
|
||||
{"network": network_module},
|
||||
)
|
||||
self.network_module_patch.start()
|
||||
_FakeTime.reset()
|
||||
|
||||
def tearDown(self):
|
||||
self.network_module_patch.stop()
|
||||
self.config_patch.stop()
|
||||
|
||||
def test_two_charging_samples_notify_server_and_exit(self):
|
||||
popen_calls = []
|
||||
with (
|
||||
mock.patch.object(power, "get_current", return_value=-200.0),
|
||||
mock.patch.object(power.os.path, "isfile", return_value=True),
|
||||
mock.patch.object(
|
||||
power.subprocess,
|
||||
"Popen",
|
||||
side_effect=lambda args: popen_calls.append(args),
|
||||
),
|
||||
):
|
||||
power.charging_shutdown_monitor()
|
||||
|
||||
self.assertEqual(_FakeTime.now_ms, 5000)
|
||||
self.assertEqual(len(popen_calls), 1)
|
||||
self.network_manager.safe_enqueue_and_wait.assert_called_once_with(
|
||||
{"poweroff": "充电中"}, 2, high=True, timeout_ms=30000
|
||||
)
|
||||
|
||||
def test_discharging_does_not_notify_or_exit(self):
|
||||
_FakeTime.reset(stop_at_ms=10000)
|
||||
popen_calls = []
|
||||
|
||||
with (
|
||||
mock.patch.object(power, "get_current", return_value=200.0),
|
||||
mock.patch.object(power.os.path, "isfile", return_value=True),
|
||||
mock.patch.object(
|
||||
power.subprocess,
|
||||
"Popen",
|
||||
side_effect=lambda args: popen_calls.append(args),
|
||||
),
|
||||
self.assertRaises(_StopMonitor),
|
||||
):
|
||||
power.charging_shutdown_monitor()
|
||||
|
||||
self.assertEqual(popen_calls, [])
|
||||
self.network_manager.safe_enqueue_and_wait.assert_not_called()
|
||||
|
||||
def test_failed_sample_resets_confirmation_count(self):
|
||||
popen_calls = []
|
||||
currents = iter((-200.0, 0.0, -200.0, -200.0))
|
||||
with (
|
||||
mock.patch.object(power, "get_current", side_effect=lambda: next(currents)),
|
||||
mock.patch.object(power.os.path, "isfile", return_value=True),
|
||||
mock.patch.object(
|
||||
power.subprocess,
|
||||
"Popen",
|
||||
side_effect=lambda args: popen_calls.append(args),
|
||||
),
|
||||
):
|
||||
power.charging_shutdown_monitor()
|
||||
|
||||
self.assertEqual(_FakeTime.now_ms, 15000)
|
||||
self.assertEqual(len(popen_calls), 1)
|
||||
self.network_manager.safe_enqueue_and_wait.assert_called_once_with(
|
||||
{"poweroff": "充电中"}, 2, high=True, timeout_ms=30000
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -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)
|
||||
@@ -0,0 +1,635 @@
|
||||
#!/usr/bin/env python3
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
离线测试脚本:直接复用 detect_circle 逻辑进行测试
|
||||
运行环境:MaixPy (Sipeed MAIX)
|
||||
"""
|
||||
import sys
|
||||
import os
|
||||
# import time
|
||||
from maix import image, time
|
||||
import cv2
|
||||
import numpy as np
|
||||
|
||||
# ==================== 全局配置 (与 test_main.py 保持一致) ====================
|
||||
REAL_RADIUS_CM = 20 # 靶心实际半径(厘米)
|
||||
|
||||
|
||||
# ==================== 复制的核心算法 ====================
|
||||
# 注意:这里直接复制了 detect_circle 的逻辑,避免 import main 导致的冲突
|
||||
|
||||
|
||||
def detect_circle_v3(frame, laser_point=None):
|
||||
"""检测图像中的靶心(优先清晰轮廓,其次黄色区域)- 返回椭圆参数版本
|
||||
增加红色圆圈检测,验证黄色圆圈是否为真正的靶心
|
||||
如果提供 laser_point,会选择最接近激光点的目标
|
||||
|
||||
Args:
|
||||
frame: 图像帧
|
||||
laser_point: 激光点坐标 (x, y),用于多目标场景下的目标选择
|
||||
|
||||
Returns:
|
||||
(result_img, best_center, best_radius, method, best_radius1, ellipse_params)
|
||||
"""
|
||||
img_cv = image.image2cv(frame, False, False)
|
||||
|
||||
best_center = best_radius = best_radius1 = method = None
|
||||
ellipse_params = None
|
||||
|
||||
# HSV 黄色掩码检测(模糊靶心)
|
||||
hsv = cv2.cvtColor(img_cv, cv2.COLOR_RGB2HSV)
|
||||
h, s, v = cv2.split(hsv)
|
||||
|
||||
# 调整饱和度策略:稍微增强,不要过度
|
||||
s = np.clip(s * 1.1, 0, 255).astype(np.uint8)
|
||||
|
||||
hsv = cv2.merge((h, s, v))
|
||||
|
||||
# 放宽 HSV 阈值范围(针对模糊图像的关键调整)
|
||||
lower_yellow = np.array([7, 80, 0]) # 饱和度下限降低,捕捉淡黄色
|
||||
upper_yellow = np.array([32, 255, 255]) # 亮度上限拉满
|
||||
|
||||
mask_yellow = cv2.inRange(hsv, lower_yellow, upper_yellow)
|
||||
|
||||
# 调整形态学操作
|
||||
kernel = cv2.getStructuringElement(cv2.MORPH_ELLIPSE, (5, 5))
|
||||
mask_yellow = cv2.morphologyEx(mask_yellow, cv2.MORPH_CLOSE, kernel)
|
||||
|
||||
contours_yellow, _ = cv2.findContours(mask_yellow, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE)
|
||||
|
||||
# 存储所有有效的黄色-红色组合
|
||||
valid_targets = []
|
||||
|
||||
if contours_yellow:
|
||||
for cnt_yellow in contours_yellow:
|
||||
area = cv2.contourArea(cnt_yellow)
|
||||
perimeter = cv2.arcLength(cnt_yellow, True)
|
||||
|
||||
# 计算圆度
|
||||
if perimeter > 0:
|
||||
circularity = (4 * np.pi * area) / (perimeter * perimeter)
|
||||
else:
|
||||
circularity = 0
|
||||
|
||||
if area > 50 and circularity > 0.7:
|
||||
print(f"[target] -> 面积:{area}, 圆度:{circularity:.2f}")
|
||||
# 尝试拟合椭圆
|
||||
yellow_center = None
|
||||
yellow_radius = None
|
||||
yellow_ellipse = None
|
||||
|
||||
if len(cnt_yellow) >= 5:
|
||||
(x, y), (width, height), angle = cv2.fitEllipse(cnt_yellow)
|
||||
yellow_ellipse = ((x, y), (width, height), angle)
|
||||
axes_minor = min(width, height)
|
||||
radius = axes_minor / 2
|
||||
yellow_center = (int(x), int(y))
|
||||
yellow_radius = int(radius)
|
||||
else:
|
||||
(x, y), radius = cv2.minEnclosingCircle(cnt_yellow)
|
||||
yellow_center = (int(x), int(y))
|
||||
yellow_radius = int(radius)
|
||||
yellow_ellipse = None
|
||||
|
||||
# 如果检测到黄色圆圈,再检测红色圆圈进行验证
|
||||
if yellow_center and yellow_radius:
|
||||
# HSV 红色掩码检测(红色在HSV中跨越0度,需要两个范围)
|
||||
# 红色范围1: 0-12度(接近0度的红色)
|
||||
# 放宽S/V阈值:S>=30, V>=20 以捕获淡红/暗红
|
||||
lower_red1 = np.array([0, 30, 20])
|
||||
upper_red1 = np.array([12, 255, 255])
|
||||
mask_red1 = cv2.inRange(hsv, lower_red1, upper_red1)
|
||||
|
||||
# 红色范围2: 168-180度(接近180度的红色)
|
||||
lower_red2 = np.array([168, 30, 20])
|
||||
upper_red2 = np.array([180, 255, 255])
|
||||
mask_red2 = cv2.inRange(hsv, lower_red2, upper_red2)
|
||||
|
||||
# 合并两个红色掩码
|
||||
mask_red = cv2.bitwise_or(mask_red1, mask_red2)
|
||||
|
||||
# 形态学操作:先CLOSE填充空洞,再DILATE加厚环状区域
|
||||
kernel_red = cv2.getStructuringElement(cv2.MORPH_ELLIPSE, (5, 5))
|
||||
mask_red = cv2.morphologyEx(mask_red, cv2.MORPH_CLOSE, kernel_red)
|
||||
mask_red = cv2.dilate(mask_red, kernel_red, iterations=1)
|
||||
|
||||
contours_red, _ = cv2.findContours(mask_red, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE)
|
||||
|
||||
red_pixel_count = np.sum(mask_red > 0)
|
||||
print(f"Debug -> 红色掩码: {red_pixel_count} 像素, {len(contours_red)} 个轮廓")
|
||||
|
||||
found_valid_red = False
|
||||
|
||||
if contours_red:
|
||||
for cnt_red in contours_red:
|
||||
area_red = cv2.contourArea(cnt_red)
|
||||
perimeter_red = cv2.arcLength(cnt_red, True)
|
||||
|
||||
if perimeter_red > 0:
|
||||
circularity_red = (4 * np.pi * area_red) / (perimeter_red * perimeter_red)
|
||||
else:
|
||||
circularity_red = 0
|
||||
|
||||
# 环状轮廓圆度可能偏低,放宽到0.2
|
||||
print(f"Debug -> 红轮廓: 面积={area_red:.1f}, 圆度={circularity_red:.2f}" +
|
||||
f" (面积>15={area_red > 15}, 圆度>0.2={circularity_red > 0.2})")
|
||||
if area_red > 15 and circularity_red > 0.2:
|
||||
if len(cnt_red) >= 5:
|
||||
(x_red, y_red), (w_red, h_red), angle_red = cv2.fitEllipse(cnt_red)
|
||||
radius_red = min(w_red, h_red) / 2
|
||||
red_center = (int(x_red), int(y_red))
|
||||
red_radius = int(radius_red)
|
||||
else:
|
||||
(x_red, y_red), radius_red = cv2.minEnclosingCircle(cnt_red)
|
||||
red_center = (int(x_red), int(y_red))
|
||||
red_radius = int(radius_red)
|
||||
|
||||
if red_center:
|
||||
dx = yellow_center[0] - red_center[0]
|
||||
dy = yellow_center[1] - red_center[1]
|
||||
distance = np.sqrt(dx * dx + dy * dy)
|
||||
|
||||
max_distance = yellow_radius * 2.0
|
||||
min_r = min(red_radius, yellow_radius)
|
||||
max_r = max(red_radius, yellow_radius)
|
||||
size_ratio = min_r / max_r if max_r > 0 else 0
|
||||
print(f"Debug -> 圆心距={distance:.1f}(阈值={max_distance:.1f}), "
|
||||
f"大小比={size_ratio:.2f}(阈值=0.5), "
|
||||
f"距离OK={distance < max_distance}, 大小OK={size_ratio > 0.5}")
|
||||
|
||||
# 允许红圈在黄圈外侧或内侧,只要大小相近(较小/较大 >= 0.5)
|
||||
if distance < max_distance and size_ratio > 0.5:
|
||||
found_valid_red = True
|
||||
print(
|
||||
f"[target] -> 找到匹配的红圈: 黄心({yellow_center}), 红心({red_center}), 距离:{distance:.1f}, 黄半径:{yellow_radius}, 红半径:{red_radius}")
|
||||
|
||||
valid_targets.append({
|
||||
'center': yellow_center,
|
||||
'radius': yellow_radius,
|
||||
'ellipse': yellow_ellipse,
|
||||
'area': area
|
||||
})
|
||||
break
|
||||
|
||||
if not found_valid_red:
|
||||
# 如果黄圈非常可靠(大且圆),在没有红圈验证时仍接受
|
||||
if area > 30 and circularity > 0.85:
|
||||
print(f"[target] -> 黄圈高置信度(面积:{area:.0f}, 圆度:{circularity:.2f}),跳过红圈验证直接接受")
|
||||
valid_targets.append({
|
||||
'center': yellow_center,
|
||||
'radius': yellow_radius,
|
||||
'ellipse': yellow_ellipse,
|
||||
'area': area
|
||||
})
|
||||
else:
|
||||
print("Debug -> 未找到匹配的红色圆圈,可能是误识别")
|
||||
|
||||
# 从所有有效目标中选择最佳目标
|
||||
if valid_targets:
|
||||
if laser_point:
|
||||
# 如果有激光点,选择最接近激光点的目标
|
||||
best_target = None
|
||||
min_distance = float('inf')
|
||||
for target in valid_targets:
|
||||
dx = target['center'][0] - laser_point[0]
|
||||
dy = target['center'][1] - laser_point[1]
|
||||
distance = np.sqrt(dx * dx + dy * dy)
|
||||
if distance < min_distance:
|
||||
min_distance = distance
|
||||
best_target = target
|
||||
if best_target:
|
||||
best_center = best_target['center']
|
||||
best_radius = best_target['radius']
|
||||
ellipse_params = best_target['ellipse']
|
||||
method = "v3_ellipse_red_validated_laser_selected"
|
||||
best_radius1 = best_radius * 5
|
||||
else:
|
||||
# 如果没有激光点,选择面积最大的目标
|
||||
best_target = max(valid_targets, key=lambda t: t['area'])
|
||||
best_center = best_target['center']
|
||||
best_radius = best_target['radius']
|
||||
ellipse_params = best_target['ellipse']
|
||||
method = "v3_ellipse_red_validated"
|
||||
best_radius1 = best_radius * 5
|
||||
|
||||
result_img = image.cv2image(img_cv, False, False)
|
||||
return result_img, best_center, best_radius, method, best_radius1, ellipse_params
|
||||
|
||||
|
||||
def detect_circle(frame):
|
||||
"""检测图像中的靶心(优先清晰轮廓,其次黄色区域)"""
|
||||
img_cv = image.image2cv(frame, False, False)
|
||||
# gray = cv2.cvtColor(img_cv, cv2.COLOR_RGB2GRAY)
|
||||
# blurred = cv2.GaussianBlur(gray, (5, 5), 0)
|
||||
# edged = cv2.Canny(blurred, 50, 150)
|
||||
# kernel = cv2.getStructuringElement(cv2.MORPH_ELLIPSE, (5, 5))
|
||||
# ceroded = cv2.erode(cv2.dilate(edged, kernel), kernel)
|
||||
|
||||
# contours, _ = cv2.findContours(ceroded, cv2.RETR_TREE, cv2.CHAIN_APPROX_SIMPLE)
|
||||
# best_center = best_radius = best_radius1 = method = None
|
||||
|
||||
# hsv = cv2.cvtColor(img_cv, cv2.COLOR_RGB2HSV)
|
||||
# h, s, v = cv2.split(hsv)
|
||||
# s = np.clip(s * 2, 0, 255).astype(np.uint8)
|
||||
# hsv = cv2.merge((h, s, v))
|
||||
# lower_yellow = np.array([7, 80, 0])
|
||||
# upper_yellow = np.array([32, 255, 182])
|
||||
# mask = cv2.inRange(hsv, lower_yellow, upper_yellow)
|
||||
# kernel = cv2.getStructuringElement(cv2.MORPH_ELLIPSE, (5, 5))
|
||||
# mask = cv2.morphologyEx(mask, cv2.MORPH_OPEN, kernel)
|
||||
# mask = cv2.morphologyEx(mask, cv2.MORPH_DILATE, kernel)
|
||||
# contours, _ = cv2.findContours(mask, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE)
|
||||
# if contours:
|
||||
# largest = max(contours, key=cv2.contourArea)
|
||||
# if cv2.contourArea(largest) > 50:
|
||||
# (x, y), radius = cv2.minEnclosingCircle(largest)
|
||||
# best_center = (int(x), int(y))
|
||||
# best_radius = int(radius)
|
||||
# best_radius1 = radius * 5
|
||||
# method = "v2"
|
||||
|
||||
# auto
|
||||
# R:31 M:v2 D:2.410110127692767
|
||||
# hsv = cv2.cvtColor(img_cv, cv2.COLOR_RGB2HSV)
|
||||
# h, s, v = cv2.split(hsv)
|
||||
|
||||
# # 1. 增强饱和度(模糊照片需要更强的增强)
|
||||
# s = np.clip(s * 2.5, 0, 255).astype(np.uint8) # 从2.0改为2.5
|
||||
|
||||
# # 2. 增强亮度(模糊照片可能偏暗)
|
||||
# v = np.clip(v * 1.2, 0, 255).astype(np.uint8) # 新增:提升亮度
|
||||
|
||||
# hsv = cv2.merge((h, s, v))
|
||||
|
||||
# # 3. 放宽HSV颜色范围(特别是模糊照片)
|
||||
# # 降低饱和度下限,提高亮度上限
|
||||
# lower_yellow = np.array([5, 50, 30]) # H:5-35, S:50-255, V:30-255
|
||||
# upper_yellow = np.array([35, 255, 255])
|
||||
|
||||
# mask = cv2.inRange(hsv, lower_yellow, upper_yellow)
|
||||
|
||||
# # 4. 增强形态学操作(连接被分割的区域)
|
||||
# kernel_small = cv2.getStructuringElement(cv2.MORPH_ELLIPSE, (5, 5))
|
||||
# kernel_large = cv2.getStructuringElement(cv2.MORPH_ELLIPSE, (9, 9)) # 更大的核
|
||||
|
||||
# # 先开运算去除噪声
|
||||
# mask = cv2.morphologyEx(mask, cv2.MORPH_OPEN, kernel_small)
|
||||
# # 多次膨胀连接区域(模糊照片需要更多膨胀)
|
||||
# mask = cv2.dilate(mask, kernel_large, iterations=2) # 增加迭代次数
|
||||
# mask = cv2.morphologyEx(mask, cv2.MORPH_CLOSE, kernel_large) # 闭运算填充空洞
|
||||
|
||||
# contours, _ = cv2.findContours(mask, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE)
|
||||
# if contours:
|
||||
# largest = max(contours, key=cv2.contourArea)
|
||||
# area = cv2.contourArea(largest)
|
||||
# if area > 50:
|
||||
# # 5. 使用面积计算等效半径(更准确)
|
||||
# equivalent_radius = np.sqrt(area / np.pi)
|
||||
|
||||
# # 6. 同时使用minEnclosingCircle作为备选(取较大值)
|
||||
# (x, y), enclosing_radius = cv2.minEnclosingCircle(largest)
|
||||
|
||||
# # 取两者中的较大值,确保不遗漏
|
||||
# radius = max(equivalent_radius, enclosing_radius)
|
||||
|
||||
# best_center = (int(x), int(y))
|
||||
# best_radius = int(radius)
|
||||
# best_radius1 = radius * 5
|
||||
# method = "v2"
|
||||
|
||||
# codegee
|
||||
# R:24 M:v2 D:3.061493895819174
|
||||
# R:22 M:v2 D:3.3644971681267077 np.clip(s * 1.1, 0, 255)
|
||||
hsv = cv2.cvtColor(img_cv, cv2.COLOR_RGB2HSV)
|
||||
h, s, v = cv2.split(hsv)
|
||||
|
||||
# 2. 调整饱和度策略:
|
||||
# 不要暴力翻倍,可以尝试稍微增强,或者使用 CLAHE 增强亮度/对比度
|
||||
# 这里我们稍微增加一点饱和度,并确保不溢出
|
||||
s = np.clip(s * 1.1, 0, 255).astype(np.uint8)
|
||||
# 对亮度通道 v 也可以做一点 CLAHE 处理来增强对比度(可选)
|
||||
# clahe = cv2.createCLAHE(clipLimit=2.0, tileGridSize=(8,8))
|
||||
# v = clahe.apply(v)
|
||||
|
||||
hsv = cv2.merge((h, s, v))
|
||||
|
||||
# 3. 放宽 HSV 阈值范围(针对模糊图像的关键调整)
|
||||
# 降低 S 的下限 (80 -> 35),提高 V 的上限 (182 -> 255)
|
||||
lower_yellow = np.array([7, 80, 0]) # 饱和度下限降低,捕捉淡黄色
|
||||
upper_yellow = np.array([32, 255, 255]) # 亮度上限拉满
|
||||
|
||||
mask = cv2.inRange(hsv, lower_yellow, upper_yellow)
|
||||
|
||||
# 4. 调整形态学操作
|
||||
# 去掉 MORPH_OPEN,因为它会减小面积。
|
||||
# 使用 MORPH_CLOSE (先膨胀后腐蚀) 来填充内部小黑洞,连接近邻区域
|
||||
kernel = cv2.getStructuringElement(cv2.MORPH_ELLIPSE, (5, 5))
|
||||
mask = cv2.morphologyEx(mask, cv2.MORPH_CLOSE, kernel)
|
||||
# 再进行一次膨胀,确保边缘被包含进来
|
||||
# mask = cv2.dilate(mask, kernel, iterations=1)
|
||||
|
||||
contours, _ = cv2.findContours(mask, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE)
|
||||
|
||||
if contours:
|
||||
largest = max(contours, key=cv2.contourArea)
|
||||
|
||||
# 这里可以适当降低面积阈值,或者保持不变
|
||||
if cv2.contourArea(largest) > 50:
|
||||
# (x, y), radius = cv2.minEnclosingCircle(largest)
|
||||
# best_center = (int(x), int(y))
|
||||
# best_radius = int(radius)
|
||||
|
||||
# --- 核心修改开始 ---
|
||||
# 1. 尝试拟合椭圆 (需要轮廓点至少为5个)
|
||||
if len(largest) >= 5:
|
||||
# 返回值: ((中心x, 中心y), (长轴, 短轴), 旋转角度)
|
||||
(x, y), (axes_major, axes_minor), angle = cv2.fitEllipse(largest)
|
||||
|
||||
# 2. 计算半径
|
||||
# 选项A:取长短轴的平均值 (比较稳健)
|
||||
# radius = (axes_major + axes_minor) / 4
|
||||
|
||||
# 选项B:直接取短轴的一半 (抗模糊最强,推荐)
|
||||
radius = axes_minor / 2
|
||||
|
||||
best_center = (int(x), int(y))
|
||||
best_radius = int(radius)
|
||||
method = "v2_ellipse"
|
||||
else:
|
||||
# 如果点太少无法拟合椭圆,降级回 minEnclosingCircle
|
||||
(x, y), radius = cv2.minEnclosingCircle(largest)
|
||||
best_center = (int(x), int(y))
|
||||
best_radius = int(radius)
|
||||
method = "v2"
|
||||
# --- 核心修改结束 ---
|
||||
|
||||
# 你的后续逻辑
|
||||
best_radius1 = radius * 5
|
||||
|
||||
# operas 4.5
|
||||
# R:25 M:v2 D:2.9554872521538527
|
||||
# hsv = cv2.cvtColor(img_cv, cv2.COLOR_RGB2HSV)
|
||||
# h, s, v = cv2.split(hsv)
|
||||
|
||||
# # 1. 适度增强饱和度(不要过度,否则噪声也会增强)
|
||||
# s = np.clip(s * 1.5, 0, 255).astype(np.uint8)
|
||||
# hsv = cv2.merge((h, s, v))
|
||||
|
||||
# # 2. 放宽 HSV 阈值范围(关键改动)
|
||||
# # - 饱和度下限从 80 降到 40(捕捉淡黄色)
|
||||
# # - 亮度上限从 182 提高到 255(允许更亮的黄色)
|
||||
# lower_yellow = np.array([7, 40, 30])
|
||||
# upper_yellow = np.array([35, 255, 255])
|
||||
|
||||
# mask = cv2.inRange(hsv, lower_yellow, upper_yellow)
|
||||
|
||||
# # 3. 调整形态学操作:用 CLOSE 替代 OPEN
|
||||
# # CLOSE(先膨胀后腐蚀):填充内部空洞,连接相邻区域
|
||||
# # OPEN(先腐蚀后膨胀):会缩小区域,不适合模糊图像
|
||||
# kernel = cv2.getStructuringElement(cv2.MORPH_ELLIPSE, (7, 7)) # 稍大的核
|
||||
# mask = cv2.morphologyEx(mask, cv2.MORPH_CLOSE, kernel)
|
||||
# mask = cv2.dilate(mask, kernel, iterations=1) # 额外膨胀,确保边缘被包含
|
||||
|
||||
# contours, _ = cv2.findContours(mask, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE)
|
||||
# if contours:
|
||||
# largest = max(contours, key=cv2.contourArea)
|
||||
# if cv2.contourArea(largest) > 50:
|
||||
# (x, y), radius = cv2.minEnclosingCircle(largest)
|
||||
# best_center = (int(x), int(y))
|
||||
# best_radius = int(radius)
|
||||
# best_radius1 = radius * 5
|
||||
# method = "v2"
|
||||
|
||||
# # --- 新增:将 Mask 叠加到原图上用于调试 ---
|
||||
# # 创建一个彩色掩码(红色通道为255,其他为0)
|
||||
# mask_overlay = np.zeros_like(img_cv)
|
||||
# mask_overlay[:, :, 2] = mask # 将掩码放在红色通道 (BGR中的R)
|
||||
#
|
||||
# cv2.addWeighted(img_cv, 0.6, mask_overlay, 0.4, 0, img_cv)
|
||||
|
||||
result_img = image.cv2image(img_cv, False, False)
|
||||
return result_img, best_center, best_radius, method, best_radius1
|
||||
|
||||
|
||||
def detect_circle_v2(frame):
|
||||
"""检测图像中的靶心(优先清晰轮廓,其次黄色区域)- 返回椭圆参数版本"""
|
||||
global REAL_RADIUS_CM
|
||||
img_cv = image.image2cv(frame, False, False)
|
||||
|
||||
best_center = best_radius = best_radius1 = method = None
|
||||
ellipse_params = None # 存储椭圆参数 ((x, y), (axes_major, axes_minor), angle)
|
||||
|
||||
# HSV 黄色掩码检测(模糊靶心)
|
||||
hsv = cv2.cvtColor(img_cv, cv2.COLOR_RGB2HSV)
|
||||
h, s, v = cv2.split(hsv)
|
||||
|
||||
# 调整饱和度策略:稍微增强,不要过度
|
||||
s = np.clip(s * 1.1, 0, 255).astype(np.uint8)
|
||||
|
||||
hsv = cv2.merge((h, s, v))
|
||||
|
||||
# 放宽 HSV 阈值范围(针对模糊图像的关键调整)
|
||||
lower_yellow = np.array([7, 80, 0]) # 饱和度下限降低,捕捉淡黄色
|
||||
upper_yellow = np.array([32, 255, 255]) # 亮度上限拉满
|
||||
|
||||
mask = cv2.inRange(hsv, lower_yellow, upper_yellow)
|
||||
|
||||
# 调整形态学操作
|
||||
# 使用 MORPH_CLOSE (先膨胀后腐蚀) 来填充内部小黑洞,连接近邻区域
|
||||
kernel = cv2.getStructuringElement(cv2.MORPH_ELLIPSE, (5, 5))
|
||||
mask = cv2.morphologyEx(mask, cv2.MORPH_CLOSE, kernel)
|
||||
|
||||
contours, _ = cv2.findContours(mask, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE)
|
||||
|
||||
if contours:
|
||||
largest = max(contours, key=cv2.contourArea)
|
||||
|
||||
if cv2.contourArea(largest) > 50:
|
||||
# 尝试拟合椭圆 (需要轮廓点至少为5个)
|
||||
if len(largest) >= 5:
|
||||
# 返回值: ((中心x, 中心y), (width, height), 旋转角度)
|
||||
# 注意:width 和 height 是外接矩形的尺寸,不是长轴和短轴
|
||||
(x, y), (width, height), angle = cv2.fitEllipse(largest)
|
||||
|
||||
# 保存椭圆参数(保持原始顺序,用于绘制)
|
||||
ellipse_params = ((x, y), (width, height), angle)
|
||||
|
||||
# 计算半径:使用较小的尺寸作为短轴
|
||||
axes_minor = min(width, height)
|
||||
radius = axes_minor / 2
|
||||
|
||||
best_center = (int(x), int(y))
|
||||
best_radius = int(radius)
|
||||
method = "v2_ellipse"
|
||||
else:
|
||||
# 如果点太少无法拟合椭圆,降级回 minEnclosingCircle
|
||||
(x, y), radius = cv2.minEnclosingCircle(largest)
|
||||
best_center = (int(x), int(y))
|
||||
best_radius = int(radius)
|
||||
method = "v2"
|
||||
ellipse_params = None # 圆形,没有椭圆参数
|
||||
|
||||
best_radius1 = radius * 5
|
||||
|
||||
result_img = image.cv2image(img_cv, False, False)
|
||||
return result_img, best_center, best_radius, method, best_radius1, ellipse_params
|
||||
|
||||
|
||||
# ==================== 测试逻辑 ====================
|
||||
|
||||
def run_offline_test(image_path):
|
||||
"""读取图片,检测圆,绘制结果,保存图片"""
|
||||
|
||||
# 1. 检查文件是否存在
|
||||
if not os.path.exists(image_path):
|
||||
print(f"[ERROR] 找不到图片文件: {image_path}")
|
||||
return
|
||||
|
||||
# 2. 使用 maix.image 读取图片 (适配 MaixPy v4)
|
||||
try:
|
||||
# 使用 image.load 读取文件,返回 Image 对象
|
||||
img = image.load(image_path)
|
||||
print(f"[INFO] 成功读取图片: {image_path} (尺寸: {img.width()}x{img.height()})")
|
||||
except Exception as e:
|
||||
print(f"[ERROR] 读取图片失败: {e}")
|
||||
print("提示:请确认 MaixPy 版本是否为 v4,且图片路径正确。")
|
||||
return
|
||||
|
||||
# 3. 调用 detect_circle_v2 函数
|
||||
print("[INFO] 正在调用 detect_circle_v2 进行检测...")
|
||||
start_time = time.ticks_ms()
|
||||
|
||||
result_img, center, radius, method, radius1, ellipse_params = detect_circle_v3(img)
|
||||
|
||||
cost_time = time.ticks_ms() - start_time
|
||||
print(f"[INFO] 检测完成,耗时: {cost_time}ms")
|
||||
print(f" 结果 -> 圆心: {center}, 半径: {radius}, 方法: {method}")
|
||||
if ellipse_params:
|
||||
(ell_center, (width, height), angle) = ellipse_params
|
||||
print(
|
||||
f" 椭圆 -> 中心: ({ell_center[0]:.1f}, {ell_center[1]:.1f}), 长轴: {max(width, height):.1f}, 短轴: {min(width, height):.1f}, 角度: {angle:.1f}°")
|
||||
|
||||
# 4. 绘制辅助线(可选,用于调试)
|
||||
if center and radius:
|
||||
# 为了绘制椭圆,需要转换回 cv2 图像
|
||||
img_cv = image.image2cv(result_img, False, False)
|
||||
|
||||
cx, cy = center
|
||||
|
||||
# 如果有椭圆参数,绘制椭圆
|
||||
if ellipse_params:
|
||||
(ell_center, (width, height), angle) = ellipse_params
|
||||
cx_ell, cy_ell = int(ell_center[0]), int(ell_center[1])
|
||||
|
||||
# 确定长轴和短轴
|
||||
if width >= height:
|
||||
# width 是长轴,height 是短轴
|
||||
axes_major = width
|
||||
axes_minor = height
|
||||
major_angle = angle # 长轴角度就是 angle
|
||||
minor_angle = angle + 90 # 短轴角度 = 长轴角度 + 90度
|
||||
else:
|
||||
# height 是长轴,width 是短轴
|
||||
axes_major = height
|
||||
axes_minor = width
|
||||
major_angle = angle + 90 # 长轴角度 = width角度 + 90度
|
||||
minor_angle = angle # 短轴角度就是 angle
|
||||
|
||||
# 使用 OpenCV 绘制椭圆(绿色,线宽2)
|
||||
cv2.ellipse(img_cv,
|
||||
(cx_ell, cy_ell), # 中心点
|
||||
(int(width / 2), int(height / 2)), # 半宽、半高
|
||||
angle, # 旋转角度(OpenCV需要原始angle)
|
||||
0, 360, # 起始和结束角度
|
||||
(0, 255, 0), # 绿色 (RGB格式)
|
||||
2) # 线宽
|
||||
|
||||
# 绘制椭圆中心点(红色)
|
||||
cv2.circle(img_cv, (cx_ell, cy_ell), 3, (255, 0, 0), -1)
|
||||
|
||||
import math
|
||||
# 绘制短轴(蓝色线条)
|
||||
minor_length = axes_minor / 2
|
||||
minor_angle_rad = math.radians(minor_angle)
|
||||
dx_minor = minor_length * math.cos(minor_angle_rad)
|
||||
dy_minor = minor_length * math.sin(minor_angle_rad)
|
||||
pt1_minor = (int(cx_ell - dx_minor), int(cy_ell - dy_minor))
|
||||
pt2_minor = (int(cx_ell + dx_minor), int(cy_ell + dy_minor))
|
||||
cv2.line(img_cv, pt1_minor, pt2_minor, (0, 0, 255), 2) # 蓝色 (RGB格式)
|
||||
else:
|
||||
# 如果没有椭圆参数,绘制圆形(红色)
|
||||
cv2.circle(img_cv, (cx, cy), radius, (0, 0, 255), 2)
|
||||
cv2.circle(img_cv, (cx, cy), 2, (0, 0, 255), -1)
|
||||
|
||||
# 转换回 maix image
|
||||
result_img = image.cv2image(img_cv, False, False)
|
||||
|
||||
# 定义颜色对象用于文字
|
||||
try:
|
||||
color_black = image.Color.from_rgb(0, 0, 0)
|
||||
except AttributeError:
|
||||
color_black = image.Color(0, 0, 0)
|
||||
|
||||
# D. 添加文字信息
|
||||
FOCAL_LENGTH_PIX = 1900
|
||||
d = (REAL_RADIUS_CM * FOCAL_LENGTH_PIX) / radius1 / 100.0
|
||||
info_str = f"R:{radius} M:{method} D:{d:.2f}"
|
||||
print(info_str)
|
||||
|
||||
# 计算文字位置,防止超出图片边界
|
||||
r_outer = int(radius * 11.0) if radius else 100
|
||||
text_y = cy - r_outer - 20 if cy > r_outer + 20 else cy + r_outer + 20
|
||||
|
||||
# 调用 draw_string
|
||||
result_img.draw_string(0, 0, info_str, color=color_black, scale=1.0)
|
||||
|
||||
# 5. 保存结果图片
|
||||
output_path = image_path.replace(".bmp", "_result.bmp")
|
||||
output_path = image_path.replace(".jpg", "_result.jpg")
|
||||
try:
|
||||
result_img.save(output_path, quality=100)
|
||||
print(f"[SUCCESS] 结果已保存至: {output_path}")
|
||||
except Exception as e:
|
||||
print(f"[ERROR] 保存图片失败: {e}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
# ================= 配置区域 =================
|
||||
|
||||
# 1. 设置要测试的图片路径
|
||||
# 建议将图片放在与脚本同级目录,或者使用绝对路径
|
||||
TARGET_IMAGE = "/root/phot/None_314_258_0_0041.bmp"
|
||||
|
||||
TARGET_DIR = "/root/phot" # 修改为你想要读取的目录路径
|
||||
|
||||
# 支持的图片格式
|
||||
IMAGE_EXTENSIONS = ['.jpg', '.jpeg', '.png', '.bmp']
|
||||
|
||||
# ================= 执行区域 =================
|
||||
if 'TARGET_DIR' in locals():
|
||||
# 读取目录下所有图片文件,过滤掉 _result.jpg 后缀的文件
|
||||
image_files = []
|
||||
if os.path.exists(TARGET_DIR) and os.path.isdir(TARGET_DIR):
|
||||
for filename in os.listdir(TARGET_DIR):
|
||||
# 检查文件扩展名
|
||||
if any(filename.lower().endswith(ext) for ext in IMAGE_EXTENSIONS):
|
||||
# 过滤掉 _result.jpg 后缀的文件
|
||||
if filename.endswith('no_target.jpg'):
|
||||
filepath = os.path.join(TARGET_DIR, filename)
|
||||
if os.path.isfile(filepath):
|
||||
image_files.append(filepath)
|
||||
|
||||
# 按文件名排序(可选)
|
||||
image_files.sort()
|
||||
|
||||
print(f"[INFO] 在目录 {TARGET_DIR} 中找到 {len(image_files)} 张图片")
|
||||
|
||||
# 处理每张图片
|
||||
for img_path in image_files:
|
||||
print(f"\n{'=' * 10} 开始处理: {img_path} {'=' * 10}")
|
||||
run_offline_test(img_path)
|
||||
else:
|
||||
print(f"[ERROR] 目录不存在或不是有效目录: {TARGET_DIR}")
|
||||
|
||||
else:
|
||||
run_offline_test(TARGET_IMAGE)
|
||||
@@ -0,0 +1,59 @@
|
||||
#!/usr/bin/env python3
|
||||
# -*- coding: utf-8 -*-
|
||||
"""Read the digital voltage level on the MaixCAM P21 pin.
|
||||
|
||||
P21 is a digital GPIO pin, not the MaixCAM analog ADC input. Therefore this
|
||||
script can only distinguish LOW and HIGH. For a continuous voltage value,
|
||||
connect the signal to the board's B3/ADC pin and use ADC channel 0 instead.
|
||||
|
||||
Do not apply more than 3.3 V to P21. Always connect the signal ground to the
|
||||
MaixCAM ground.
|
||||
"""
|
||||
|
||||
from maix import app, gpio, pinmap, time
|
||||
|
||||
|
||||
PIN = "P21"
|
||||
IO_HIGH_VOLTAGE = 3.3
|
||||
SAMPLE_INTERVAL_MS = 200
|
||||
|
||||
|
||||
def find_gpio_function(pin):
|
||||
"""Return the GPIO function supported by the requested physical pin."""
|
||||
functions = pinmap.get_pin_functions(pin)
|
||||
gpio_functions = [name for name in functions if name.startswith("GPIO")]
|
||||
|
||||
print(f"{pin} supported functions: {', '.join(functions)}")
|
||||
if not gpio_functions:
|
||||
raise RuntimeError(f"{pin} does not provide a GPIO input function")
|
||||
|
||||
return gpio_functions[0]
|
||||
|
||||
|
||||
def main():
|
||||
gpio_function = find_gpio_function(PIN)
|
||||
pinmap.set_pin_function(PIN, gpio_function)
|
||||
voltage_input = gpio.GPIO(gpio_function, gpio.Mode.IN)
|
||||
|
||||
print(f"Reading {PIN} through {gpio_function}")
|
||||
print("P21 only reports LOW/HIGH; displayed voltage is an estimate.")
|
||||
print("Press the MaixCAM exit key to stop.")
|
||||
|
||||
while not app.need_exit():
|
||||
level = voltage_input.value()
|
||||
estimated_voltage = IO_HIGH_VOLTAGE if level else 0.0
|
||||
state = "HIGH" if level else "LOW"
|
||||
print(
|
||||
f"{PIN}: level={level}, state={state}, "
|
||||
f"estimated_voltage={estimated_voltage:.1f} V"
|
||||
)
|
||||
time.sleep_ms(SAMPLE_INTERVAL_MS)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
try:
|
||||
main()
|
||||
except Exception as error:
|
||||
print(f"P21 voltage detection failed: {error}")
|
||||
print("Check that this MaixCAM model exposes P21 as a GPIO pin.")
|
||||
raise
|
||||
@@ -0,0 +1,139 @@
|
||||
import json
|
||||
import sys
|
||||
import types
|
||||
import unittest
|
||||
|
||||
|
||||
class _FakeTime:
|
||||
@staticmethod
|
||||
def sleep(_seconds):
|
||||
pass
|
||||
|
||||
@staticmethod
|
||||
def sleep_ms(_milliseconds):
|
||||
pass
|
||||
|
||||
@staticmethod
|
||||
def ticks_ms():
|
||||
return 0
|
||||
|
||||
@staticmethod
|
||||
def ticks_diff(left, right):
|
||||
return left - right
|
||||
|
||||
|
||||
class _FakeLogger:
|
||||
def debug(self, *_args, **_kwargs):
|
||||
pass
|
||||
|
||||
def info(self, *_args, **_kwargs):
|
||||
pass
|
||||
|
||||
def warning(self, *_args, **_kwargs):
|
||||
pass
|
||||
|
||||
def error(self, *_args, **_kwargs):
|
||||
pass
|
||||
|
||||
|
||||
class _FakeSocket:
|
||||
def __init__(self, recv_data=b""):
|
||||
self.recv_data = recv_data
|
||||
self.closed = False
|
||||
|
||||
def close(self):
|
||||
self.closed = True
|
||||
|
||||
def recv(self, _size, *_flags):
|
||||
return self.recv_data
|
||||
|
||||
|
||||
class _StopAfterCallback:
|
||||
def __init__(self):
|
||||
self.stopped = False
|
||||
|
||||
def is_set(self):
|
||||
return self.stopped
|
||||
|
||||
|
||||
maix_module = types.ModuleType("maix")
|
||||
maix_module.time = _FakeTime
|
||||
maix_module.network = types.SimpleNamespace()
|
||||
maix_module.err = types.SimpleNamespace()
|
||||
sys.modules.setdefault("maix", maix_module)
|
||||
sys.modules.setdefault("ujson", json)
|
||||
|
||||
netcore_module = types.ModuleType("archery_netcore")
|
||||
netcore_module.get_config = lambda: {"SERVER_IP": "127.0.0.1", "SERVER_PORT": 1234}
|
||||
netcore_module.parse_packet = lambda _packet: (0, {})
|
||||
netcore_module.make_packet = lambda *_args, **_kwargs: b""
|
||||
netcore_module.actions_for_inner_cmd = lambda *_args, **_kwargs: []
|
||||
sys.modules["archery_netcore"] = netcore_module
|
||||
|
||||
hardware_module = types.ModuleType("hardware")
|
||||
hardware_module.hardware_manager = types.SimpleNamespace()
|
||||
sys.modules["hardware"] = hardware_module
|
||||
|
||||
power_module = types.ModuleType("power")
|
||||
power_module.get_bus_voltage = lambda: 0
|
||||
power_module.voltage_to_percent = lambda _voltage: 0
|
||||
sys.modules["power"] = power_module
|
||||
|
||||
import logger_manager
|
||||
import wifi
|
||||
import network
|
||||
|
||||
|
||||
class WiFiFailoverTests(unittest.TestCase):
|
||||
def setUp(self):
|
||||
logger_manager.logger_manager._logger = _FakeLogger()
|
||||
|
||||
def test_monitor_switches_when_sta_association_is_lost(self):
|
||||
manager = wifi.wifi_manager
|
||||
stop_event = _StopAfterCallback()
|
||||
callbacks = []
|
||||
|
||||
manager._wifi_socket = _FakeSocket()
|
||||
manager._wifi_quality_stop_event = stop_event
|
||||
manager._network_type_callback = lambda: "wifi"
|
||||
manager.is_sta_associated = lambda: False
|
||||
manager._get_wifi_rssi_dbm = lambda: None
|
||||
|
||||
def on_poor_quality():
|
||||
callbacks.append(True)
|
||||
stop_event.stopped = True
|
||||
|
||||
manager._on_poor_quality_callback = on_poor_quality
|
||||
manager._quality_monitor_loop()
|
||||
|
||||
self.assertEqual(callbacks, [True])
|
||||
self.assertIsNone(manager.last_wifi_rtt_ms)
|
||||
|
||||
def test_tls_connection_check_rejects_lost_sta_association(self):
|
||||
manager = network.network_manager
|
||||
sock = _FakeSocket()
|
||||
wifi.wifi_manager._wifi_socket = sock
|
||||
wifi.wifi_manager._wifi_connected = True
|
||||
wifi.wifi_manager._wifi_ip = "192.168.1.2"
|
||||
wifi.wifi_manager.is_sta_associated = lambda: False
|
||||
manager._tcp_connected = True
|
||||
|
||||
self.assertFalse(manager._check_wifi_connection())
|
||||
self.assertTrue(sock.closed)
|
||||
self.assertIsNone(wifi.wifi_manager.wifi_socket)
|
||||
self.assertFalse(manager.tcp_connected)
|
||||
|
||||
def test_receive_eof_marks_wifi_tcp_disconnected(self):
|
||||
manager = network.network_manager
|
||||
sock = _FakeSocket(recv_data=b"")
|
||||
wifi.wifi_manager._wifi_socket = sock
|
||||
manager._tcp_connected = True
|
||||
|
||||
self.assertEqual(manager.receive_tcp_data_via_wifi(), b"")
|
||||
self.assertTrue(sock.closed)
|
||||
self.assertIsNone(wifi.wifi_manager.wifi_socket)
|
||||
self.assertFalse(manager.tcp_connected)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
+38
@@ -0,0 +1,38 @@
|
||||
# 1.2.0 开始使用C++编译成.so,替换部分代码
|
||||
# 1.2.1 ota使用加密包
|
||||
# 1.2.2 支持wifi ota,并且设定时区,并使用单独线程保存图片
|
||||
# 1.2.3 修改ADC_TRIGGER_THRESHOLD 为2300,支持上传日志到服务器
|
||||
# 1.2.4 修改ADC_TRIGGER_THRESHOLD 为3000,并默认关闭摄像头的显示,并把ADC的采样间隔从50ms降低到10ms
|
||||
# 1.2.5 支持空气传感器采样,并默认关闭日志。优化断网时的发送队列丢消息问题,解决 WiFi 断线检测不可靠问题。
|
||||
# 1.2.6 在链接 wifi 前先判断 wifi 的可用性,假如不可用,则不落盘。增加日志批量压缩上传功能
|
||||
# 1.2.7 修复OTA失败的bug, 空气压力传感器的阈值是2500
|
||||
# 1.2.8 (1) 加快 wifi 下数据传输的速度。(2) 调整射箭时处理的逻辑,优先上报数据,再存照片之类的操作。(3)假如是用户打开激光的,射箭触发后不再关闭激光,因为是调瞄阶段
|
||||
# 1.2.9 增加电源板的控制和自动关机的功能
|
||||
# 1.2.10 config formal
|
||||
# 1.2.11 增加三角形的单应性算法,适配对应的靶纸
|
||||
# 1.2.110 关掉了黑色三角形算法,只用于测试
|
||||
# 1.2.13 修改wifi连接
|
||||
# 1.2.14 修改了icc登录部分
|
||||
# 2.15.3 新版本ota,去除ai算环数方法
|
||||
# 2.15.4 更新版本号
|
||||
# 2.15.5 打印ota进度
|
||||
# 2.15.6 更新版本号
|
||||
# 2.15.7 更新版本号
|
||||
# 2.15.8 启动不加载预加载yolo
|
||||
# 2.15.9 20cm
|
||||
# 2.15.10 不保存图片
|
||||
# 2.15.11 优化内存
|
||||
# 2.15.12 优化算法
|
||||
# 2.15.13 优化算法
|
||||
# 2.15.14 优化算法
|
||||
# 2.15.15 优化wifi连接
|
||||
# 2.15.16 修复wifi连接问题
|
||||
# 2.15.17 修复wifi连接问题
|
||||
# 2.15.18 wifi连接成功重新登录
|
||||
# 2.15.20 加了充电关机,激光也同时关闭
|
||||
# 2.15.21 测试4g 扩大了缓存池和改了心跳时间
|
||||
# 2.15.22 修复了4g网络和wifi切换问题
|
||||
# 2.15.23 合并充电关机与稳定版网络修复
|
||||
# 2.15.24 空改测试
|
||||
# 2.15.25 修复整合后关机失败和ota格式更新问题
|
||||
# 2.15.26
|
||||
+1
-23
@@ -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.31'
|
||||
|
||||
|
||||
|
||||
@@ -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,20 +570,22 @@ 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, 30, 20]), np.array([12, 255, 255])),
|
||||
cv2.inRange(hsv, np.array([168, 30, 20]), 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)
|
||||
# 再加一次膨胀,加厚环状区域避免碎片化
|
||||
mask_red = cv2.dilate(mask_red, kernel_red, iterations=1)
|
||||
contours_red, _ = cv2.findContours(mask_red, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE)
|
||||
# 预先把红色轮廓筛选成 (center, radius) 列表,后续直接查表
|
||||
red_candidates = []
|
||||
for cnt_r in contours_red:
|
||||
ar = cv2.contourArea(cnt_r)
|
||||
if ar <= 50:
|
||||
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.2:
|
||||
continue
|
||||
if len(cnt_r) >= 5:
|
||||
(xr, yr), (wr, hr), _ = cv2.fitEllipse(cnt_r)
|
||||
@@ -599,13 +601,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 +627,11 @@ 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:
|
||||
max_dist = yellow_radius * 2.0
|
||||
min_r = min(rc["radius"], yellow_radius)
|
||||
max_r = max(rc["radius"], yellow_radius)
|
||||
size_ratio = min_r / max_r if max_r > 0 else 0
|
||||
if dist_centers < max_dist and size_ratio >= 0.3:
|
||||
if logger:
|
||||
logger.info(f"[target] -> 找到匹配的红圈: 黄心({yellow_center}), "
|
||||
f"红心({rc['center']}), 距离:{dist_centers:.1f}, "
|
||||
@@ -638,8 +644,17 @@ def detect_circle_v3(frame, laser_point=None, img_cv=None):
|
||||
})
|
||||
matched = True
|
||||
break
|
||||
if not matched and logger:
|
||||
logger.debug("Debug -> 未找到匹配的红色圆圈,可能是误识别")
|
||||
if not matched:
|
||||
# 黄圈高置信度兜底:大且圆时跳过红圈验证
|
||||
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()}")
|
||||
|
||||
|
||||
@@ -41,6 +41,7 @@ class WiFiManager:
|
||||
# WiFi 质量监测(后台线程)
|
||||
self._wifi_quality_monitor_thread = None
|
||||
self._wifi_quality_stop_event = threading.Event()
|
||||
self._wifi_quality_lock = threading.Lock()
|
||||
self._last_wifi_rtt_ms = None # 最近一次测量的 RTT
|
||||
self._last_wifi_rssi_dbm = None # 最近一次测量的 RSSI
|
||||
|
||||
@@ -238,7 +239,6 @@ class WiFiManager:
|
||||
old_conf = _read_text(conf_path)
|
||||
old_boot_ssid = _read_text(ssid_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:
|
||||
@@ -250,9 +250,13 @@ class WiFiManager:
|
||||
_write_text(conf_path, full_conf)
|
||||
except Exception:
|
||||
pass
|
||||
_write_text(boot_wpa_path, full_conf)
|
||||
# 删除 wpa_supplicant.conf,让 S30wifi 回退读 ssid/pass
|
||||
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(pass_file, password.strip())
|
||||
|
||||
@@ -292,7 +296,6 @@ class WiFiManager:
|
||||
if not persist:
|
||||
# 不持久化:把 /boot 恢复成旧值(不重启,当前连接保持不变)
|
||||
_restore_boot(old_boot_ssid, old_boot_pass)
|
||||
_restore_boot_wpa(old_boot_wpa)
|
||||
self.logger.info("[WIFI] 网络验证通过,但按 persist=False 回滚 /boot 凭证(不重启)")
|
||||
else:
|
||||
self.logger.info("[WIFI] 网络验证通过,/boot 凭证已保留(持久化)")
|
||||
@@ -306,7 +309,6 @@ class WiFiManager:
|
||||
except Exception as e:
|
||||
# 失败:回滚 /boot 和 /etc,重启 WiFi 恢复旧网络
|
||||
_restore_boot(old_boot_ssid, old_boot_pass)
|
||||
_restore_boot_wpa(old_boot_wpa)
|
||||
try:
|
||||
if old_conf is not None:
|
||||
_write_text(conf_path, old_conf)
|
||||
@@ -351,7 +353,11 @@ class WiFiManager:
|
||||
else:
|
||||
full_conf = build_sta_conf_open(ssid)
|
||||
_write_text(conf_path, full_conf)
|
||||
_write_text(boot_wpa_path, full_conf)
|
||||
try:
|
||||
if os.path.exists(boot_wpa_path):
|
||||
os.remove(boot_wpa_path)
|
||||
except Exception:
|
||||
pass
|
||||
except ValueError as e:
|
||||
return False, str(e)
|
||||
except Exception as e:
|
||||
@@ -535,73 +541,88 @@ class WiFiManager:
|
||||
|
||||
def start_quality_monitor(self, network_type_callback, on_poor_quality_callback):
|
||||
"""
|
||||
启动 WiFi 质量后台监测线程(每 5 秒测量一次 RTT 和 RSSI)
|
||||
启动 WiFi 质量后台监测线程(每 5 秒检查 STA 关联状态和 RSSI)
|
||||
只在 WiFi 连接时运行,不影响业务发送性能
|
||||
|
||||
Args:
|
||||
network_type_callback: 获取当前网络类型的回调函数
|
||||
on_poor_quality_callback: WiFi质量差时的回调函数
|
||||
"""
|
||||
if self._wifi_quality_monitor_thread is not None:
|
||||
self.logger.warning("[WiFi Monitor] 监测线程已在运行")
|
||||
return
|
||||
|
||||
self._network_type_callback = network_type_callback
|
||||
self._on_poor_quality_callback = on_poor_quality_callback
|
||||
self._wifi_quality_stop_event.clear()
|
||||
self._wifi_quality_monitor_thread = threading.Thread(
|
||||
target=self._quality_monitor_loop,
|
||||
daemon=True,
|
||||
name="wifi_quality_monitor"
|
||||
)
|
||||
self._wifi_quality_monitor_thread.start()
|
||||
self.logger.info("[WiFi Monitor] 已启动后台监测线程")
|
||||
with self._wifi_quality_lock:
|
||||
if self._wifi_quality_monitor_thread is not None and self._wifi_quality_monitor_thread.is_alive():
|
||||
self.logger.warning("[WiFi Monitor] 监测线程已在运行")
|
||||
return
|
||||
|
||||
self._network_type_callback = network_type_callback
|
||||
self._on_poor_quality_callback = on_poor_quality_callback
|
||||
self._wifi_quality_stop_event.clear()
|
||||
self._wifi_quality_monitor_thread = threading.Thread(
|
||||
target=self._quality_monitor_loop,
|
||||
daemon=True,
|
||||
name="wifi_quality_monitor"
|
||||
)
|
||||
self._wifi_quality_monitor_thread.start()
|
||||
self.logger.info("[WiFi Monitor] 已启动后台监测线程")
|
||||
|
||||
def stop_quality_monitor(self):
|
||||
"""停止 WiFi 质量监测线程"""
|
||||
if self._wifi_quality_monitor_thread is None:
|
||||
return
|
||||
with self._wifi_quality_lock:
|
||||
t = self._wifi_quality_monitor_thread
|
||||
if t is None:
|
||||
return
|
||||
if not t.is_alive():
|
||||
self._wifi_quality_monitor_thread = None
|
||||
return
|
||||
|
||||
self._wifi_quality_stop_event.set()
|
||||
try:
|
||||
self._wifi_quality_monitor_thread.join(timeout=2.0)
|
||||
t.join(timeout=2.0)
|
||||
except Exception as e:
|
||||
self.logger.error(f"[WiFi Monitor] 停止线程失败:{e}")
|
||||
finally:
|
||||
self._wifi_quality_monitor_thread = None
|
||||
self.logger.info("[WiFi Monitor] 已停止后台监测线程")
|
||||
|
||||
with self._wifi_quality_lock:
|
||||
if t is self._wifi_quality_monitor_thread:
|
||||
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):
|
||||
"""
|
||||
WiFi 质量监测循环(后台线程)
|
||||
每 5 秒测量一次 RTT 和 RSSI,发现质量差则触发切换
|
||||
每 5 秒检查 STA 关联状态和 RSSI,发现断链或质量差则触发切换
|
||||
"""
|
||||
while not self._wifi_quality_stop_event.is_set():
|
||||
try:
|
||||
# 只在 WiFi 连接时才测量
|
||||
network_type = self._network_type_callback()
|
||||
if network_type == "wifi" and self._wifi_socket:
|
||||
# # 测量 RTT(1 个样本,快速测量)
|
||||
# rtt_ms, reachable = self._measure_wifi_tcp_rtt_ms(
|
||||
# self._server_ip, self._server_port,
|
||||
# samples=1, per_sample_timeout_ms=600
|
||||
# )
|
||||
|
||||
# RTT 测量当前禁用;STA 关联状态用于判断物理 WiFi 链路是否仍存在。
|
||||
# 不能把禁用的 RTT 伪装成 0ms,否则关闭热点后会一直被判为正常。
|
||||
reachable = self.is_sta_associated()
|
||||
rtt_ms = None
|
||||
|
||||
# 获取 RSSI
|
||||
rssi_dbm = self._get_wifi_rssi_dbm()
|
||||
|
||||
|
||||
# 更新缓存
|
||||
# 不使用 RTT 测量
|
||||
rtt_ms = 0
|
||||
reachable = True
|
||||
self._last_wifi_rtt_ms = rtt_ms if reachable else None
|
||||
self._last_wifi_rtt_ms = rtt_ms
|
||||
self._last_wifi_rssi_dbm = rssi_dbm
|
||||
_rtt_s = f"{rtt_ms:.0f}ms" if rtt_ms is not None else "n/a"
|
||||
_rssi_s = f"{rssi_dbm:.0f}" if rssi_dbm is not None else "n/a"
|
||||
self.logger.debug(f"[WiFi Monitor] - RTT={rtt_ms:.0f}ms, RSSI={_rssi_s}dBm")
|
||||
self.logger.debug(
|
||||
f"[WiFi Monitor] - associated={reachable}, RTT={_rtt_s}, RSSI={_rssi_s}dBm"
|
||||
)
|
||||
|
||||
# 判断质量是否差(切换前做 2 次快速复测,防止瞬时抖动)
|
||||
def _is_bad_now(_reachable, _rtt, _rssi):
|
||||
if (not _reachable) or (_rtt is None) or (_rtt == float("inf")):
|
||||
if not _reachable:
|
||||
return True
|
||||
# RTT 未启用时不参与质量判断;链路状态仍由 STA 关联保证。
|
||||
if _rtt is None:
|
||||
return False
|
||||
if _rtt == float("inf"):
|
||||
return True
|
||||
return self._is_wifi_quality_bad(_rtt, _rssi)
|
||||
|
||||
@@ -611,13 +632,8 @@ class WiFiManager:
|
||||
|
||||
for retry_idx in range(2):
|
||||
time.sleep_ms(1000)
|
||||
# 不使用 RTT 测量
|
||||
rtt2 = 0
|
||||
reachable2 = True
|
||||
# rtt2, reachable2 = self._measure_wifi_tcp_rtt_ms(
|
||||
# self._server_ip, self._server_port,
|
||||
# samples=1, per_sample_timeout_ms=600
|
||||
# )
|
||||
reachable2 = self.is_sta_associated()
|
||||
rtt2 = None
|
||||
rssi2 = self._get_wifi_rssi_dbm()
|
||||
|
||||
# 更新缓存,便于外部查看最新状态
|
||||
@@ -626,14 +642,10 @@ class WiFiManager:
|
||||
|
||||
bad2 = _is_bad_now(reachable2, rtt2, rssi2)
|
||||
try:
|
||||
_rtt_disp = (
|
||||
rtt2
|
||||
if rtt2 is not None and rtt2 != float("inf")
|
||||
else -1
|
||||
)
|
||||
_rtt_disp = f"{rtt2:.0f}ms" if rtt2 is not None else "n/a"
|
||||
self.logger.info(
|
||||
f"[WiFi Monitor] 复测{retry_idx+1}/2: reachable={reachable2}, "
|
||||
f"rtt={_rtt_disp:.0f}ms, rssi={rssi2}, bad={bad2}"
|
||||
f"rtt={_rtt_disp}, rssi={rssi2}, bad={bad2}"
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
Reference in New Issue
Block a user