Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2834f5b9b7 | ||
|
|
e67c410325 | ||
|
|
0c8ab1508f | ||
|
|
d30c432143 | ||
|
|
c5338ccac7 | ||
|
|
231937afba | ||
|
|
70aa072164 | ||
|
|
42026d43e5 | ||
|
|
8a83deddd3 | ||
|
|
6a1d3fe2bd | ||
|
|
1fee464924 | ||
|
|
06994c5905 | ||
|
|
23755f48ae | ||
|
|
5f509488c5 | ||
|
|
c0bb245c8c | ||
|
|
9cfc871645 | ||
|
|
27f96d8bce | ||
|
|
80e780b931 | ||
|
|
3683033abf | ||
|
|
f0df9ad915 | ||
|
|
3fcd38f417 |
@@ -1 +0,0 @@
|
|||||||
*.sh text eol=lf
|
|
||||||
@@ -1,4 +1,3 @@
|
|||||||
/cpp_ext/build/
|
/cpp_ext/build/
|
||||||
/.cursor/
|
/.cursor/
|
||||||
/dist/
|
/dist/
|
||||||
.idea
|
|
||||||
Vendored
+1
-1
@@ -1,3 +1,3 @@
|
|||||||
{
|
{
|
||||||
"cmake.sourceDirectory": "E:/code/code/code/new/new/new/archery/cpp_ext"
|
"cmake.sourceDirectory": "E:/code/code/code/new/new/new/new/new/nw/2.17.0/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.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -1,6 +1,6 @@
|
|||||||
id: t11
|
id: t11
|
||||||
name: t11
|
name: t11
|
||||||
version: 2.15.35
|
version: 2.17.18
|
||||||
author: t11
|
author: t11
|
||||||
icon: ''
|
icon: ''
|
||||||
desc: t11
|
desc: t11
|
||||||
@@ -12,15 +12,14 @@ files:
|
|||||||
- at_client.py
|
- at_client.py
|
||||||
- camera_manager.py
|
- camera_manager.py
|
||||||
- cameraParameters.xml
|
- cameraParameters.xml
|
||||||
- charging_exit.sh
|
|
||||||
- config.py
|
- config.py
|
||||||
- hardware.py
|
- hardware.py
|
||||||
- laser_detector.py
|
- laser_detector.py
|
||||||
- laser_manager.py
|
- laser_manager.py
|
||||||
- logger_manager.py
|
- logger_manager.py
|
||||||
- main.py
|
- main.py
|
||||||
- model_285484.cvimodel
|
- model_317828.cvimodel
|
||||||
- model_285484.mud
|
- model_317828.mud
|
||||||
- network.py
|
- network.py
|
||||||
- ota_curl.sh
|
- ota_curl.sh
|
||||||
- ota_manager.py
|
- ota_manager.py
|
||||||
|
|||||||
+1
-37
@@ -69,7 +69,7 @@ class ATClient:
|
|||||||
# 同上:避免在 _reader_loop 持锁期间二次 acquire
|
# 同上:避免在 _reader_loop 持锁期间二次 acquire
|
||||||
self._http_events.append(ev)
|
self._http_events.append(ev)
|
||||||
|
|
||||||
def send(self, cmd: str, expect: str = "OK", timeout_ms: int = 2000, abort_event=None):
|
def send(self, cmd: str, expect: str = "OK", timeout_ms: int = 2000):
|
||||||
"""
|
"""
|
||||||
发送 AT 命令并等待 expect(子串匹配)。
|
发送 AT 命令并等待 expect(子串匹配)。
|
||||||
注意:expect=">" 用于等待 prompt。
|
注意:expect=">" 用于等待 prompt。
|
||||||
@@ -90,9 +90,6 @@ class ATClient:
|
|||||||
|
|
||||||
t0 = time.ticks_ms()
|
t0 = time.ticks_ms()
|
||||||
while abs(time.ticks_diff(time.ticks_ms(), t0)) < timeout_ms:
|
while abs(time.ticks_diff(time.ticks_ms(), t0)) < timeout_ms:
|
||||||
if abort_event is not None and abort_event.is_set():
|
|
||||||
self._waiting = False
|
|
||||||
break
|
|
||||||
if (not self._waiting) or (self._expect in self._resp):
|
if (not self._waiting) or (self._expect in self._resp):
|
||||||
self._waiting = False
|
self._waiting = False
|
||||||
break
|
break
|
||||||
@@ -105,39 +102,6 @@ class ATClient:
|
|||||||
except:
|
except:
|
||||||
return str(self._resp)
|
return str(self._resp)
|
||||||
|
|
||||||
def send_raw_and_wait(self, data: bytes, expect: str = "OK", timeout_ms: int = 1000,
|
|
||||||
suffix: bytes = b""):
|
|
||||||
"""Register the response waiter before writing raw UART data."""
|
|
||||||
expect_b = expect.encode() if isinstance(expect, str) else expect
|
|
||||||
with self._cmd_lock:
|
|
||||||
with self._q_lock:
|
|
||||||
self._waiting = True
|
|
||||||
self._expect = expect_b
|
|
||||||
self._resp = b""
|
|
||||||
|
|
||||||
total = 0
|
|
||||||
while total < len(data):
|
|
||||||
n = self.uart.write(data[total:])
|
|
||||||
if not n or n < 0:
|
|
||||||
time.sleep_ms(1)
|
|
||||||
continue
|
|
||||||
total += n
|
|
||||||
if suffix:
|
|
||||||
self.uart.write(suffix)
|
|
||||||
|
|
||||||
t0 = time.ticks_ms()
|
|
||||||
while abs(time.ticks_diff(time.ticks_ms(), t0)) < timeout_ms:
|
|
||||||
if (not self._waiting) or (self._expect in self._resp):
|
|
||||||
self._waiting = False
|
|
||||||
break
|
|
||||||
time.sleep_ms(5)
|
|
||||||
|
|
||||||
self._waiting = False
|
|
||||||
try:
|
|
||||||
return self._resp.decode(errors="ignore")
|
|
||||||
except:
|
|
||||||
return str(self._resp)
|
|
||||||
|
|
||||||
def _find_urc_tag(self, tag: bytes):
|
def _find_urc_tag(self, tag: bytes):
|
||||||
"""
|
"""
|
||||||
只在"真正的 URC 边界"查找 tag,避免误命中 HTTP payload 内容。
|
只在"真正的 URC 边界"查找 tag,避免误命中 HTTP payload 内容。
|
||||||
|
|||||||
@@ -1,47 +0,0 @@
|
|||||||
#!/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
|
|
||||||
@@ -234,10 +234,10 @@ TRIANGLE_BLACKHAT_KERNEL_FRAC = 0.018 # 核大小 ≈ min(h,w)*frac,取奇数
|
|||||||
# ── YOLO(NPU) 靶环 ROI → 裁剪后再跑三角形(减小 CPU 处理面积)──────────────────
|
# ── YOLO(NPU) 靶环 ROI → 裁剪后再跑三角形(减小 CPU 处理面积)──────────────────
|
||||||
# 日志里 net_in=W×H 来自 .mud 模型(det.input_width/height),不是这里配置的。
|
# 日志里 net_in=W×H 来自 .mud 模型(det.input_width/height),不是这里配置的。
|
||||||
TRIANGLE_YOLO_ROI_ENABLE = True
|
TRIANGLE_YOLO_ROI_ENABLE = True
|
||||||
TRIANGLE_YOLO_MODEL_PATH = APP_DIR + "/model_270139.mud"
|
TRIANGLE_YOLO_MODEL_PATH = APP_DIR + "/model_317211.mud"
|
||||||
# 参与 ROI 的类别:多类时只填「整靶/靶环」的 id;不要填角标类,否则 union 仍可对,但 largest 会偏小。
|
# 参与 ROI 的类别:多类时只填「整靶/靶环」的 id;不要填角标类,否则 union 仍可对,但 largest 会偏小。
|
||||||
TRIANGLE_YOLO_RING_CLASS_IDS = (0,)
|
TRIANGLE_YOLO_RING_CLASS_IDS = (0,)
|
||||||
TRIANGLE_YOLO_CONF_TH = 0.7
|
TRIANGLE_YOLO_CONF_TH = 0.9
|
||||||
TRIANGLE_YOLO_IOU_TH = 0.45
|
TRIANGLE_YOLO_IOU_TH = 0.45
|
||||||
# YOLO 首次/临界帧可能在高阈值下 0 框;启用后仅在 0 候选时用较低阈值重试一次。
|
# YOLO 首次/临界帧可能在高阈值下 0 框;启用后仅在 0 候选时用较低阈值重试一次。
|
||||||
# 后续仍会经过 min_box_side、ROI aspect、三角形几何校验,避免直接放大假阳性。
|
# 后续仍会经过 min_box_side、ROI aspect、三角形几何校验,避免直接放大假阳性。
|
||||||
@@ -262,11 +262,11 @@ TRIANGLE_SAMPLE_PATCH_HALF_PX = 2
|
|||||||
# 开机阶段预加载 YOLO detector;detect 使用 dual_buff=False,避免返回上一帧结果。
|
# 开机阶段预加载 YOLO detector;detect 使用 dual_buff=False,避免返回上一帧结果。
|
||||||
TRIANGLE_YOLO_PRELOAD_ON_BOOT = False
|
TRIANGLE_YOLO_PRELOAD_ON_BOOT = False
|
||||||
|
|
||||||
# YOLO 靶规格识别:class 0=20cm,class 1=40cm。
|
# YOLO target size classification: class 0=20cm, class 1=40cm.
|
||||||
TARGET_CLASS_YOLO_ENABLE = True
|
TARGET_CLASS_YOLO_ENABLE = True
|
||||||
TARGET_CLASS_YOLO_MODEL_PATH = APP_DIR + "/model_285484.mud"
|
TARGET_CLASS_YOLO_MODEL_PATH = APP_DIR + "/model_317828.mud"
|
||||||
TARGET_CLASS_YOLO_LABELS = (20, 40)
|
TARGET_CLASS_YOLO_LABELS = (20, 40)
|
||||||
TARGET_CLASS_YOLO_CONF_TH = 0.50
|
TARGET_CLASS_YOLO_CONF_TH = 0.66
|
||||||
TARGET_CLASS_YOLO_IOU_TH = 0.45
|
TARGET_CLASS_YOLO_IOU_TH = 0.45
|
||||||
TARGET_CLASS_YOLO_RETRY_ON_EMPTY = False
|
TARGET_CLASS_YOLO_RETRY_ON_EMPTY = False
|
||||||
TARGET_CLASS_YOLO_RETRY_CONF_TH = 0.25
|
TARGET_CLASS_YOLO_RETRY_CONF_TH = 0.25
|
||||||
@@ -326,9 +326,13 @@ LOG_QUEUE_MAXSIZE = 10000 # 日志队列上限
|
|||||||
MAX_CMD_THREADS = 10 # 并发命令线程上限(防止服务器下发命令时无限创建线程)
|
MAX_CMD_THREADS = 10 # 并发命令线程上限(防止服务器下发命令时无限创建线程)
|
||||||
|
|
||||||
# ==================== 图像保存配置 ====================
|
# ==================== 图像保存配置 ====================
|
||||||
SAVE_IMAGE_ENABLED = False # 是否保存图像(True=保存,False=不保存)
|
SAVE_IMAGE_ENABLED = True # 是否保存图像(True=保存,False=不保存)
|
||||||
|
SAVE_IMAGE_ON_FAILURE = True # 检测失败时是否强制保存图像(供调试测试用)
|
||||||
PHOTO_DIR = "/root/phot" # 照片存储目录
|
PHOTO_DIR = "/root/phot" # 照片存储目录
|
||||||
MAX_IMAGES = 1000
|
MAX_IMAGES = 1000
|
||||||
|
SAVE_RAW_IMAGE_ENABLED = False # 额外保存完整原始帧(不画框、不画点、不裁剪)
|
||||||
|
RAW_IMAGE_DIR = PHOTO_DIR + "/raw"
|
||||||
|
RAW_IMAGE_MAX_IMAGES = MAX_IMAGES
|
||||||
# Stage2 调试目录(默认 PHOTO_DIR/stage2_roi)内 JPEG 最多保留张数;None 表示与 MAX_IMAGES 相同
|
# Stage2 调试目录(默认 PHOTO_DIR/stage2_roi)内 JPEG 最多保留张数;None 表示与 MAX_IMAGES 相同
|
||||||
TRIANGLE_BLACK_YOLO_STAGE2_ROI_MAX_IMAGES = None
|
TRIANGLE_BLACK_YOLO_STAGE2_ROI_MAX_IMAGES = None
|
||||||
|
|
||||||
@@ -353,18 +357,6 @@ PIN_MAPPINGS = {
|
|||||||
# ==================== 电源配置 ====================
|
# ==================== 电源配置 ====================
|
||||||
AUTO_POWER_OFF_IN_SECONDS = 10 * 60 # 自动关机时间(秒),0表示不自动关机
|
AUTO_POWER_OFF_IN_SECONDS = 10 * 60 # 自动关机时间(秒),0表示不自动关机
|
||||||
|
|
||||||
# 实机数据:正常放电约为正电流,插入充电线后约为负电流。
|
|
||||||
CHARGING_SHUTDOWN_ENABLED = True # True=充电时退出应用,False=关闭充电关机功能
|
|
||||||
CHARGING_DIAGNOSTIC_LOG_ENABLED = False
|
|
||||||
CHARGING_CHECK_INTERVAL_MS = 3000
|
|
||||||
CHARGING_CURRENT_THRESHOLD_MA = 100.0
|
|
||||||
CHARGING_CONFIRM_COUNT = 2
|
|
||||||
CHARGING_NOTIFY_TIMEOUT_MS = 30000
|
|
||||||
CHARGING_4G_UART_LOCK_TIMEOUT_SEC = 2.5
|
|
||||||
CHARGING_4G_PROMPT_TIMEOUT_MS = 1500
|
|
||||||
CHARGING_4G_CONFIRM_TIMEOUT_MS = 1000
|
|
||||||
CHARGING_EXIT_SCRIPT = APP_DIR + "/charging_exit.sh"
|
|
||||||
|
|
||||||
BATTERY_SOC_LPF_ALPHA = 0.5
|
BATTERY_SOC_LPF_ALPHA = 0.5
|
||||||
BATTERY_SOC_AVG_WINDOW = 5
|
BATTERY_SOC_AVG_WINDOW = 5
|
||||||
|
|
||||||
|
|||||||
@@ -1,20 +0,0 @@
|
|||||||
<!doctype html>
|
|
||||||
<html lang="zh-CN">
|
|
||||||
<head>
|
|
||||||
<meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1">
|
|
||||||
<meta name="description" content="Weusing · 思考、记录与实践">
|
|
||||||
<title>Weusing · 思考与记录</title>
|
|
||||||
<link rel="stylesheet" href="/style.css">
|
|
||||||
</head>
|
|
||||||
<body>
|
|
||||||
<header class="top"><a class="brand" href="/">WEUSING<span>.</span></a><nav><a href="#posts">文章</a><a href="#about">关于</a></nav></header>
|
|
||||||
<main>
|
|
||||||
<section class="intro"><p class="kicker">A SMALL NOTEBOOK ON THE WEB</p><h1>把想法写下来,<br><em>让时间看得见。</em></h1><p class="lede">这里记录技术、产品和生活里的小发现。保持好奇,持续构建。</p></section>
|
|
||||||
<section id="posts" class="posts"><div class="section-head"><h2>最新文章</h2><span>2026 / 08</span></div>
|
|
||||||
<article><div class="date">08.13<br><small>2026</small></div><div><p class="tag">TECHNOLOGY</p><h3>让静态网站快起来:从请求到首屏的几件小事</h3><p class="excerpt">更少的依赖、更短的路径,以及一些值得长期坚持的工程习惯。</p></div><a class="arrow" href="#">↗</a></article>
|
|
||||||
<article><div class="date">08.06<br><small>2026</small></div><div><p class="tag">NOTES</p><h3>在复杂系统里,保留一条清晰的路</h3><p class="excerpt">记录一次调试过程,也记录那些最终留下来的判断。</p></div><a class="arrow" href="#">↗</a></article>
|
|
||||||
<article><div class="date">07.28<br><small>2026</small></div><div><p class="tag">LIFE</p><h3>慢一点,观察风从哪里来</h3><p class="excerpt">日常、远方和一些不急着得到答案的问题。</p></div><a class="arrow" href="#">↗</a></article>
|
|
||||||
</section>
|
|
||||||
<section id="about" class="about"><p class="kicker">ABOUT THIS SPACE</p><p>Weusing 是一个个人写作空间。愿每一次发布,都比上一次更接近真实。</p></section>
|
|
||||||
</main><footer><span>© 2026 WEUSING</span><span>BUILT WITH HTML & CSS</span></footer>
|
|
||||||
</body></html>
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
:root{--ink:#202326;--muted:#747a80;--line:#d9d7d1;--accent:#d95d39;--paper:#f5f3ee}*{box-sizing:border-box}html{scroll-behavior:smooth}body{margin:0;background:var(--paper);color:var(--ink);font-family:Arial,"Noto Sans SC",sans-serif}.top,main,footer{max-width:1080px;margin:auto}.top{height:88px;display:flex;align-items:center;justify-content:space-between;border-bottom:1px solid var(--line)}.brand{font-size:18px;letter-spacing:2px;font-weight:700;color:var(--ink);text-decoration:none}.brand span{color:var(--accent)}nav{display:flex;gap:30px}nav a{font-size:13px;color:var(--muted);text-decoration:none}nav a:hover{color:var(--accent)}.intro{padding:105px 0 115px;border-bottom:1px solid var(--line)}.kicker,.tag{font-size:11px;letter-spacing:2px;color:var(--accent);font-weight:700}.intro h1{font-size:clamp(42px,7vw,78px);line-height:1.1;letter-spacing:-2px;margin:25px 0}.intro em{font-family:Georgia,serif;font-weight:400;color:#555;font-style:italic}.lede{max-width:390px;color:var(--muted);font-size:16px;line-height:1.8}.posts{padding:72px 0}.section-head{display:flex;align-items:baseline;justify-content:space-between;border-bottom:2px solid var(--ink);padding-bottom:16px}.section-head h2{font-size:25px;margin:0}.section-head span{font-size:12px;color:var(--muted)}article{display:grid;grid-template-columns:90px 1fr 32px;gap:28px;padding:32px 0;border-bottom:1px solid var(--line);align-items:start}.date{font-size:14px;color:var(--accent);line-height:1.45;font-weight:700}.date small{font-size:11px;color:var(--muted);font-weight:400}article h3{font-size:23px;line-height:1.35;margin:9px 0 8px;font-weight:500}article .tag{margin:0;color:var(--muted);font-size:10px}.excerpt{color:var(--muted);font-size:14px;line-height:1.7;margin:0}.arrow{color:var(--accent);font-size:22px;text-decoration:none}.about{padding:30px 0 110px;display:grid;grid-template-columns:1fr 2fr;gap:30px;border-top:1px solid var(--line)}.about p:last-child{font:italic 27px/1.5 Georgia,serif;max-width:620px;margin:0}footer{border-top:1px solid var(--line);padding:25px 0 35px;display:flex;justify-content:space-between;color:var(--muted);font-size:10px;letter-spacing:1px}@media(max-width:700px){.top,main,footer{margin:0 22px}.top{height:70px}.intro{padding:70px 0 75px}.intro h1{letter-spacing:-1px}article{grid-template-columns:58px 1fr 20px;gap:14px}article h3{font-size:18px}.about{grid-template-columns:1fr;padding-bottom:75px}.about p:last-child{font-size:23px}footer{margin:0;padding:22px}.section-head{margin-top:0}}
|
|
||||||
@@ -120,7 +120,7 @@ def cmd_str():
|
|||||||
|
|
||||||
# ==================== 第二阶段:软件初始化 ====================
|
# ==================== 第二阶段:软件初始化 ====================
|
||||||
|
|
||||||
# 1. 初始化日志系统
|
# 1. 初始化日志系统(WARNING级别,不打印/写入INFO和DEBUG日志,提高执行流畅度)
|
||||||
import logging
|
import logging
|
||||||
logger_manager.init_logging(log_level=logging.WARNING)
|
logger_manager.init_logging(log_level=logging.WARNING)
|
||||||
logger = logger_manager.logger
|
logger = logger_manager.logger
|
||||||
@@ -132,6 +132,7 @@ def cmd_str():
|
|||||||
sync_system_time_from_4g()
|
sync_system_time_from_4g()
|
||||||
|
|
||||||
# 2.1 WiFi 热点配网兜底:仅当 STA 与 4G 均不可用时起 AP + HTTP;提交后删 /boot/wifi.ap、建 wifi.sta 并 reboot
|
# 2.1 WiFi 热点配网兜底:仅当 STA 与 4G 均不可用时起 AP + HTTP;提交后删 /boot/wifi.ap、建 wifi.sta 并 reboot
|
||||||
|
_ota_pending_path = f"{config.APP_DIR}/ota_pending.json"
|
||||||
try:
|
try:
|
||||||
from wifi_config_httpd import maybe_start_wifi_ap_fallback
|
from wifi_config_httpd import maybe_start_wifi_ap_fallback
|
||||||
|
|
||||||
@@ -167,14 +168,16 @@ def cmd_str():
|
|||||||
and bool(getattr(config, "TARGET_CLASS_YOLO_PRELOAD_ON_BOOT", True))
|
and bool(getattr(config, "TARGET_CLASS_YOLO_PRELOAD_ON_BOOT", True))
|
||||||
)
|
)
|
||||||
_preload_yolo = _preload_yolo or _need_black_preload or _need_target_preload
|
_preload_yolo = _preload_yolo or _need_black_preload or _need_target_preload
|
||||||
if _preload_yolo:
|
if _preload_yolo and not os.path.exists(_ota_pending_path):
|
||||||
preload_yolo_detector(logger)
|
preload_yolo_detector(logger)
|
||||||
|
elif _preload_yolo and logger:
|
||||||
|
logger.warning("[YOLO] ota_pending.json found; skip model preload until rollback check")
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
if logger:
|
if logger:
|
||||||
logger.warning(f"[YOLO-ROI] 启动预加载异常(不影响后续射箭): {e}")
|
logger.warning(f"[YOLO-ROI] 启动预加载异常(不影响后续射箭): {e}")
|
||||||
|
|
||||||
# 3. 启动时检查:是否需要恢复备份
|
# 3. 启动时检查:是否需要恢复备份
|
||||||
pending_path = f"{config.APP_DIR}/ota_pending.json"
|
pending_path = _ota_pending_path
|
||||||
if os.path.exists(pending_path):
|
if os.path.exists(pending_path):
|
||||||
try:
|
try:
|
||||||
with open(pending_path, 'r', encoding='utf-8') as f:
|
with open(pending_path, 'r', encoding='utf-8') as f:
|
||||||
@@ -249,8 +252,12 @@ def cmd_str():
|
|||||||
# 4. 初始化设备ID(network_manager 内部会自动设置 device_id 和 password)
|
# 4. 初始化设备ID(network_manager 内部会自动设置 device_id 和 password)
|
||||||
network_manager.read_device_id()
|
network_manager.read_device_id()
|
||||||
|
|
||||||
# 5. 创建照片存储目录(如果启用图像保存)
|
# 5. 创建照片存储目录(如果启用图像保存或检测失败时强制保存)
|
||||||
if config.SAVE_IMAGE_ENABLED:
|
if (
|
||||||
|
config.SAVE_IMAGE_ENABLED
|
||||||
|
or getattr(config, "SAVE_IMAGE_ON_FAILURE", False)
|
||||||
|
or getattr(config, "SAVE_RAW_IMAGE_ENABLED", False)
|
||||||
|
):
|
||||||
photo_dir = config.PHOTO_DIR
|
photo_dir = config.PHOTO_DIR
|
||||||
if photo_dir not in os.listdir("/root"):
|
if photo_dir not in os.listdir("/root"):
|
||||||
try:
|
try:
|
||||||
@@ -282,6 +289,13 @@ def cmd_str():
|
|||||||
logger.info("系统准备完成...")
|
logger.info("系统准备完成...")
|
||||||
|
|
||||||
last_adc_trigger = 0
|
last_adc_trigger = 0
|
||||||
|
trigger_adc_val = 0 # 触发时的气压值,气压需降回此值以下才能再次触发
|
||||||
|
# 读取一次ADC初始值,防止开机时传感器已有压力导致误触发
|
||||||
|
enable_check = True
|
||||||
|
try:
|
||||||
|
last_adc_val = hardware_manager.adc_obj.read()
|
||||||
|
except Exception:
|
||||||
|
last_adc_val = 0
|
||||||
# 气压采样:减少日志频率(每 N 个点输出一条),避免 logger.debug 拖慢采样
|
# 气压采样:减少日志频率(每 N 个点输出一条),避免 logger.debug 拖慢采样
|
||||||
PRESSURE_BATCH_SIZE = 100
|
PRESSURE_BATCH_SIZE = 100
|
||||||
|
|
||||||
@@ -333,7 +347,6 @@ def cmd_str():
|
|||||||
time.sleep_ms(250)
|
time.sleep_ms(250)
|
||||||
continue
|
continue
|
||||||
|
|
||||||
# todo 去除或者不在这里检测
|
|
||||||
# 不在 OTA 状态下,检测是否空闲足够长,自动关机
|
# 不在 OTA 状态下,检测是否空闲足够长,自动关机
|
||||||
# print(f"[MAIN] 空闲时间: {hardware_manager.get_idle_time_in_sec() }秒")
|
# print(f"[MAIN] 空闲时间: {hardware_manager.get_idle_time_in_sec() }秒")
|
||||||
# print(f"配置关机时间:{config.AUTO_POWER_OFF_IN_SECONDS} 秒")
|
# print(f"配置关机时间:{config.AUTO_POWER_OFF_IN_SECONDS} 秒")
|
||||||
@@ -372,16 +385,16 @@ def cmd_str():
|
|||||||
pressure_max = adc_val
|
pressure_max = adc_val
|
||||||
if len(pressure_buf) >= PRESSURE_BATCH_SIZE:
|
if len(pressure_buf) >= PRESSURE_BATCH_SIZE:
|
||||||
_flush_pressure_buf("batch")
|
_flush_pressure_buf("batch")
|
||||||
# if adc_val >= 2000:
|
# 突变增量检测:压力增量大于400时触发
|
||||||
# print(f"adc :{adc_val}")
|
# 触发后需等气压降到触发值以下才重新检测增量
|
||||||
if adc_val >= config.ADC_TRIGGER_THRESHOLD:
|
if adc_val < trigger_adc_val :
|
||||||
|
enable_check = True
|
||||||
|
if (adc_val - last_adc_val) > 200 and enable_check:
|
||||||
hardware_manager.start_idle_timer() # 重新计时
|
hardware_manager.start_idle_timer() # 重新计时
|
||||||
diff_ms = current_time - last_adc_trigger
|
|
||||||
if diff_ms < 3000:
|
|
||||||
logger.info(f"[MAIN] 扳机触发过于频繁, {diff_ms}ms")
|
|
||||||
continue
|
|
||||||
last_adc_trigger = current_time
|
last_adc_trigger = current_time
|
||||||
# 触发前先把缓存刷出来,避免波形被长耗时处理截断
|
trigger_adc_val = adc_val # 记录触发时的气压值
|
||||||
|
last_adc_val = adc_val # 更新基准值,防止连续增量误触发
|
||||||
|
enable_check = False
|
||||||
_flush_pressure_buf("before_trigger")
|
_flush_pressure_buf("before_trigger")
|
||||||
|
|
||||||
try:
|
try:
|
||||||
@@ -399,10 +412,9 @@ def cmd_str():
|
|||||||
try:
|
try:
|
||||||
camera_manager.show(camera_manager.read_frame())
|
camera_manager.show(camera_manager.read_frame())
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger = logger_manager.logger
|
pass
|
||||||
if logger:
|
time.sleep_ms(1)
|
||||||
logger.error(f"[MAIN] 显示异常: {e}")
|
last_adc_val = adc_val
|
||||||
time.sleep_ms(5)
|
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
# 主循环的顶层异常捕获,防止程序静默退出
|
# 主循环的顶层异常捕获,防止程序静默退出
|
||||||
|
|||||||
Binary file not shown.
@@ -1,7 +1,7 @@
|
|||||||
|
|
||||||
[basic]
|
[basic]
|
||||||
type = cvimodel
|
type = cvimodel
|
||||||
model = model_270139.cvimodel
|
model = model_317189.cvimodel
|
||||||
|
|
||||||
[extra]
|
[extra]
|
||||||
model_type = yolov5
|
model_type = yolov5
|
||||||
@@ -9,5 +9,5 @@ input_type = rgb
|
|||||||
mean = 0, 0, 0
|
mean = 0, 0, 0
|
||||||
scale = 0.00392156862745098, 0.00392156862745098, 0.00392156862745098
|
scale = 0.00392156862745098, 0.00392156862745098, 0.00392156862745098
|
||||||
anchors = 10, 13, 16, 30, 33, 23, 30, 61, 62, 45, 59, 119, 116, 90, 156, 198, 373, 326
|
anchors = 10, 13, 16, 30, 33, 23, 30, 61, 62, 45, 59, 119, 116, 90, 156, 198, 373, 326
|
||||||
labels = 黑三角和圆环
|
labels = circle, triangle
|
||||||
|
|
||||||
Binary file not shown.
@@ -0,0 +1,13 @@
|
|||||||
|
|
||||||
|
[basic]
|
||||||
|
type = cvimodel
|
||||||
|
model = model_317211.cvimodel
|
||||||
|
|
||||||
|
[extra]
|
||||||
|
model_type = yolov5
|
||||||
|
input_type = rgb
|
||||||
|
mean = 0, 0, 0
|
||||||
|
scale = 0.00392156862745098, 0.00392156862745098, 0.00392156862745098
|
||||||
|
anchors = 10, 13, 16, 30, 33, 23, 30, 61, 62, 45, 59, 119, 116, 90, 156, 198, 373, 326
|
||||||
|
labels = circle, triangle
|
||||||
|
|
||||||
Binary file not shown.
@@ -1,7 +1,7 @@
|
|||||||
|
|
||||||
[basic]
|
[basic]
|
||||||
type = cvimodel
|
type = cvimodel
|
||||||
model = model_270820.cvimodel
|
model = model_317423.cvimodel
|
||||||
|
|
||||||
[extra]
|
[extra]
|
||||||
model_type = yolov5
|
model_type = yolov5
|
||||||
@@ -9,5 +9,5 @@ input_type = rgb
|
|||||||
mean = 0, 0, 0
|
mean = 0, 0, 0
|
||||||
scale = 0.00392156862745098, 0.00392156862745098, 0.00392156862745098
|
scale = 0.00392156862745098, 0.00392156862745098, 0.00392156862745098
|
||||||
anchors = 10, 13, 16, 30, 33, 23, 30, 61, 62, 45, 59, 119, 116, 90, 156, 198, 373, 326
|
anchors = 10, 13, 16, 30, 33, 23, 30, 61, 62, 45, 59, 119, 116, 90, 156, 198, 373, 326
|
||||||
labels = triangle
|
labels = 20, 10, 40
|
||||||
|
|
||||||
Binary file not shown.
@@ -0,0 +1,13 @@
|
|||||||
|
|
||||||
|
[basic]
|
||||||
|
type = cvimodel
|
||||||
|
model = model_317704.cvimodel
|
||||||
|
|
||||||
|
[extra]
|
||||||
|
model_type = yolov5
|
||||||
|
input_type = rgb
|
||||||
|
mean = 0, 0, 0
|
||||||
|
scale = 0.00392156862745098, 0.00392156862745098, 0.00392156862745098
|
||||||
|
anchors = 10, 13, 16, 30, 33, 23, 30, 61, 62, 45, 59, 119, 116, 90, 156, 198, 373, 326
|
||||||
|
labels = 40, circle, triangle
|
||||||
|
|
||||||
Binary file not shown.
@@ -1,7 +1,7 @@
|
|||||||
|
|
||||||
[basic]
|
[basic]
|
||||||
type = cvimodel
|
type = cvimodel
|
||||||
model = model_285484.cvimodel
|
model = model_317828.cvimodel
|
||||||
|
|
||||||
[extra]
|
[extra]
|
||||||
model_type = yolov5
|
model_type = yolov5
|
||||||
+11
-155
@@ -67,7 +67,6 @@ class NetworkManager:
|
|||||||
self._queue_lock = threading.Lock()
|
self._queue_lock = threading.Lock()
|
||||||
self._send_event = threading.Event()
|
self._send_event = threading.Event()
|
||||||
self._uart4g_lock = threading.Lock()
|
self._uart4g_lock = threading.Lock()
|
||||||
self._terminal_send_event = threading.Event()
|
|
||||||
self._device_id = None
|
self._device_id = None
|
||||||
self._password = None
|
self._password = None
|
||||||
self._raw_line_data = []
|
self._raw_line_data = []
|
||||||
@@ -679,7 +678,7 @@ class NetworkManager:
|
|||||||
except OSError:
|
except OSError:
|
||||||
pass
|
pass
|
||||||
w = network.wifi.Wifi()
|
w = network.wifi.Wifi()
|
||||||
e = w.connect(ssid, password, wait=True, timeout=10)
|
e = w.connect(ssid, password, wait=True, timeout=15)
|
||||||
err.check_raise(e, "connect wifi failed")
|
err.check_raise(e, "connect wifi failed")
|
||||||
if self.logger:
|
if self.logger:
|
||||||
self.logger.info(f"[ota] Connect success, got ip{w.get_ip()}")
|
self.logger.info(f"[ota] Connect success, got ip{w.get_ip()}")
|
||||||
@@ -712,35 +711,6 @@ class NetworkManager:
|
|||||||
"""线程安全地将消息加入队列(公共方法)"""
|
"""线程安全地将消息加入队列(公共方法)"""
|
||||||
self._enqueue((msg_type, data_dict), high)
|
self._enqueue((msg_type, data_dict), high)
|
||||||
|
|
||||||
def safe_enqueue_and_wait(self, data_dict, msg_type=2, high=False, timeout_ms=30000):
|
|
||||||
"""将消息加入队列,并等待网络线程确认已写入 TCP 连接。"""
|
|
||||||
sent_event = threading.Event()
|
|
||||||
self._enqueue((msg_type, data_dict, sent_event), high)
|
|
||||||
return bool(sent_event.wait(max(0, int(timeout_ms)) / 1000.0))
|
|
||||||
|
|
||||||
def safe_replace_queue_and_wait(self, data_dict, msg_type=2, timeout_ms=30000):
|
|
||||||
"""Drop queued messages, enqueue one terminal message, and wait for its TCP write."""
|
|
||||||
sent_event = threading.Event()
|
|
||||||
with self._queue_lock:
|
|
||||||
self._high_send_queue.clear()
|
|
||||||
self._normal_send_queue.clear()
|
|
||||||
self._high_send_queue.append((msg_type, data_dict, sent_event))
|
|
||||||
self._send_event.set()
|
|
||||||
return bool(sent_event.wait(max(0, int(timeout_ms)) / 1000.0))
|
|
||||||
|
|
||||||
def safe_terminal_send_and_wait(self, data_dict, msg_type=2, timeout_ms=30000):
|
|
||||||
"""Cancel ordinary 4G waits and replace queued work with one terminal message."""
|
|
||||||
sent_event = threading.Event()
|
|
||||||
result = {"sent": False}
|
|
||||||
self._terminal_send_event.set()
|
|
||||||
with self._queue_lock:
|
|
||||||
self._high_send_queue.clear()
|
|
||||||
self._normal_send_queue.clear()
|
|
||||||
self._high_send_queue.append((msg_type, data_dict, sent_event, "terminal", result))
|
|
||||||
self._send_event.set()
|
|
||||||
completed = sent_event.wait(max(0, int(timeout_ms)) / 1000.0)
|
|
||||||
return bool(completed and result["sent"])
|
|
||||||
|
|
||||||
def connect_server(self):
|
def connect_server(self):
|
||||||
"""
|
"""
|
||||||
连接到服务器(自动选择WiFi或4G)
|
连接到服务器(自动选择WiFi或4G)
|
||||||
@@ -927,12 +897,6 @@ class NetworkManager:
|
|||||||
"""检查WiFi TCP连接是否仍然有效"""
|
"""检查WiFi TCP连接是否仍然有效"""
|
||||||
if not wifi_manager.wifi_socket:
|
if not wifi_manager.wifi_socket:
|
||||||
return False
|
return False
|
||||||
# TLS socket 无法可靠使用 MSG_PEEK,但物理 WiFi 链路仍可通过 STA 关联状态判断。
|
|
||||||
if not wifi_manager.is_sta_associated():
|
|
||||||
self.logger.warning("[WIFI-TCP] STA 已断开,关闭 WiFi TCP 并重新选网")
|
|
||||||
wifi_manager.disconnect_wifi()
|
|
||||||
self._tcp_connected = False
|
|
||||||
return False
|
|
||||||
# TLS(ssl.wrap_socket/SSLContext.wrap_socket) 后的 socket 往往不支持 MSG_PEEK/MSG_DONTWAIT。
|
# TLS(ssl.wrap_socket/SSLContext.wrap_socket) 后的 socket 往往不支持 MSG_PEEK/MSG_DONTWAIT。
|
||||||
# 这种情况下“主动探测”反而容易误报断线;让真正的 send/recv 去判定更稳。
|
# 这种情况下“主动探测”反而容易误报断线;让真正的 send/recv 去判定更稳。
|
||||||
try:
|
try:
|
||||||
@@ -1140,12 +1104,8 @@ class NetworkManager:
|
|||||||
return False
|
return False
|
||||||
try:
|
try:
|
||||||
for _ in range(max_retries):
|
for _ in range(max_retries):
|
||||||
if self._terminal_send_event.is_set():
|
|
||||||
return False
|
|
||||||
cmd = f'AT+MIPSEND={link_id},{len(data)}'
|
cmd = f'AT+MIPSEND={link_id},{len(data)}'
|
||||||
if ">" not in hardware_manager.at_client.send(cmd, ">", 2000):
|
if ">" not in hardware_manager.at_client.send(cmd, ">", 2000):
|
||||||
if self._terminal_send_event.is_set():
|
|
||||||
return False
|
|
||||||
time.sleep_ms(50)
|
time.sleep_ms(50)
|
||||||
continue
|
continue
|
||||||
|
|
||||||
@@ -1160,73 +1120,14 @@ class NetworkManager:
|
|||||||
hardware_manager.uart4g.write(b"\x1A")
|
hardware_manager.uart4g.write(b"\x1A")
|
||||||
with hardware_manager.at_client._q_lock:
|
with hardware_manager.at_client._q_lock:
|
||||||
hardware_manager.at_client._rx = b""
|
hardware_manager.at_client._rx = b""
|
||||||
r = hardware_manager.at_client.send(
|
r = hardware_manager.at_client.send("", "OK", 8000)
|
||||||
"", "OK", 8000, abort_event=self._terminal_send_event
|
|
||||||
)
|
|
||||||
if ("SEND OK" in r) or ("OK" in r) or ("+MIPSEND" in r):
|
if ("SEND OK" in r) or ("OK" in r) or ("+MIPSEND" in r):
|
||||||
return True
|
return True
|
||||||
if self._terminal_send_event.is_set():
|
|
||||||
return False
|
|
||||||
time.sleep_ms(50)
|
time.sleep_ms(50)
|
||||||
return False
|
return False
|
||||||
finally:
|
finally:
|
||||||
self._uart4g_lock.release()
|
self._uart4g_lock.release()
|
||||||
|
|
||||||
def _tcp_send_terminal_raw(self, data: bytes) -> bool:
|
|
||||||
if not self._tcp_connected:
|
|
||||||
return False
|
|
||||||
if self._network_type == "wifi":
|
|
||||||
return self._tcp_send_raw_via_wifi(data, max_retries=1)
|
|
||||||
if self._network_type != "4g":
|
|
||||||
return False
|
|
||||||
|
|
||||||
link_id = getattr(config, "TCP_LINK_ID", 0)
|
|
||||||
lock_timeout_sec = float(
|
|
||||||
getattr(config, "CHARGING_4G_UART_LOCK_TIMEOUT_SEC", 2.5)
|
|
||||||
)
|
|
||||||
prompt_timeout_ms = int(
|
|
||||||
getattr(config, "CHARGING_4G_PROMPT_TIMEOUT_MS", 1500)
|
|
||||||
)
|
|
||||||
confirm_timeout_ms = int(
|
|
||||||
getattr(config, "CHARGING_4G_CONFIRM_TIMEOUT_MS", 1000)
|
|
||||||
)
|
|
||||||
lock_start_ms = time.ticks_ms()
|
|
||||||
if not self._uart4g_lock.acquire(timeout=max(0.0, lock_timeout_sec)):
|
|
||||||
self.logger.warning(
|
|
||||||
f"[CHARGE-4G] uart_lock timeout timeout_sec={lock_timeout_sec}"
|
|
||||||
)
|
|
||||||
return False
|
|
||||||
try:
|
|
||||||
lock_elapsed_ms = abs(time.ticks_diff(time.ticks_ms(), lock_start_ms))
|
|
||||||
cmd = f'AT+MIPSEND={link_id},{len(data)}'
|
|
||||||
prompt_start_ms = time.ticks_ms()
|
|
||||||
if ">" not in hardware_manager.at_client.send(
|
|
||||||
cmd, ">", max(0, prompt_timeout_ms)):
|
|
||||||
prompt_elapsed_ms = abs(time.ticks_diff(time.ticks_ms(), prompt_start_ms))
|
|
||||||
self.logger.warning(
|
|
||||||
f"[CHARGE-4G] prompt failed lock_ms={lock_elapsed_ms} "
|
|
||||||
f"prompt_ms={prompt_elapsed_ms}"
|
|
||||||
)
|
|
||||||
return False
|
|
||||||
prompt_elapsed_ms = abs(time.ticks_diff(time.ticks_ms(), prompt_start_ms))
|
|
||||||
confirm_start_ms = time.ticks_ms()
|
|
||||||
r = hardware_manager.at_client.send_raw_and_wait(
|
|
||||||
data,
|
|
||||||
expect="OK",
|
|
||||||
timeout_ms=max(0, confirm_timeout_ms),
|
|
||||||
suffix=b"\x1A",
|
|
||||||
)
|
|
||||||
confirm_elapsed_ms = abs(time.ticks_diff(time.ticks_ms(), confirm_start_ms))
|
|
||||||
sent = ("SEND OK" in r) or ("OK" in r) or ("+MIPSEND" in r)
|
|
||||||
self.logger.warning(
|
|
||||||
f"[CHARGE-4G] send_done lock_ms={lock_elapsed_ms} "
|
|
||||||
f"prompt_ms={prompt_elapsed_ms} confirm_ms={confirm_elapsed_ms} "
|
|
||||||
f"sent={sent}"
|
|
||||||
)
|
|
||||||
return sent
|
|
||||||
finally:
|
|
||||||
self._uart4g_lock.release()
|
|
||||||
|
|
||||||
def _configure_ssl_before_connect(self, link_id: int) -> bool:
|
def _configure_ssl_before_connect(self, link_id: int) -> bool:
|
||||||
"""按手册:MSSLCFG(auth) -> (可选) MSSLCERTWR -> MSSLCFG(cert) -> MIPCFG(ssl)"""
|
"""按手册:MSSLCFG(auth) -> (可选) MSSLCERTWR -> MSSLCFG(cert) -> MIPCFG(ssl)"""
|
||||||
ssl_id = getattr(config, "SSL_ID", 1)
|
ssl_id = getattr(config, "SSL_ID", 1)
|
||||||
@@ -1311,14 +1212,6 @@ class NetworkManager:
|
|||||||
# 这里保持 socket 为非阻塞模式(连接时已 setblocking(False))。
|
# 这里保持 socket 为非阻塞模式(连接时已 setblocking(False))。
|
||||||
# 不要反复 settimeout(),否则会把 socket 切回"阻塞+超时",并导致 conncheck 误报 timed out。
|
# 不要反复 settimeout(),否则会把 socket 切回"阻塞+超时",并导致 conncheck 误报 timed out。
|
||||||
data = wifi_manager.wifi_socket.recv(4096) # 每次最多接收4KB(无数据会抛 BlockingIOError)
|
data = wifi_manager.wifi_socket.recv(4096) # 每次最多接收4KB(无数据会抛 BlockingIOError)
|
||||||
if data == b"":
|
|
||||||
self.logger.warning("[WIFI-TCP] 对端已关闭连接")
|
|
||||||
try:
|
|
||||||
wifi_manager.wifi_socket.close()
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
wifi_manager.wifi_socket = None
|
|
||||||
self._tcp_connected = False
|
|
||||||
return data
|
return data
|
||||||
|
|
||||||
except BlockingIOError:
|
except BlockingIOError:
|
||||||
@@ -1919,9 +1812,16 @@ class NetworkManager:
|
|||||||
self.logger.info("[NET] TCP主线程启动")
|
self.logger.info("[NET] TCP主线程启动")
|
||||||
|
|
||||||
send_hartbeat_fail_count = 0
|
send_hartbeat_fail_count = 0
|
||||||
|
last_charging_check = 0
|
||||||
|
CHARGING_CHECK_INTERVAL = 5000 # 5秒检查一次充电状态
|
||||||
|
|
||||||
while True:
|
while True:
|
||||||
try:
|
try:
|
||||||
|
# 检查充电状态(每5秒检查一次)
|
||||||
|
current_time = time.ticks_ms()
|
||||||
|
if current_time - last_charging_check > CHARGING_CHECK_INTERVAL:
|
||||||
|
last_charging_check = current_time
|
||||||
|
|
||||||
# OTA 期间不要 connect/登录/心跳/发送
|
# OTA 期间不要 connect/登录/心跳/发送
|
||||||
try:
|
try:
|
||||||
from ota_manager import ota_manager
|
from ota_manager import ota_manager
|
||||||
@@ -1963,25 +1863,12 @@ class NetworkManager:
|
|||||||
pending_cleared = False
|
pending_cleared = False
|
||||||
last_heartbeat_ack_time = time.ticks_ms()
|
last_heartbeat_ack_time = time.ticks_ms()
|
||||||
last_heartbeat_send_time = time.ticks_ms()
|
last_heartbeat_send_time = time.ticks_ms()
|
||||||
last_wifi_sta_check_time = time.ticks_ms()
|
|
||||||
|
|
||||||
while True:
|
while True:
|
||||||
# 如果底层连接已断开,尽快跳出内层循环触发重连/重选网络
|
# 如果底层连接已断开,尽快跳出内层循环触发重连/重选网络
|
||||||
if not self._tcp_connected:
|
if not self._tcp_connected:
|
||||||
break
|
break
|
||||||
|
|
||||||
if self._network_type == "wifi":
|
|
||||||
now_ms = time.ticks_ms()
|
|
||||||
if abs(time.ticks_diff(now_ms, last_wifi_sta_check_time)) >= 1000:
|
|
||||||
last_wifi_sta_check_time = now_ms
|
|
||||||
if not wifi_manager.is_sta_associated():
|
|
||||||
self.logger.warning(
|
|
||||||
"[WIFI-TCP] STA disconnected; leave WiFi session and reselect network"
|
|
||||||
)
|
|
||||||
wifi_manager.disconnect_wifi()
|
|
||||||
self._tcp_connected = False
|
|
||||||
break
|
|
||||||
|
|
||||||
# OTA 期间暂停 TCP 活动
|
# OTA 期间暂停 TCP 活动
|
||||||
try:
|
try:
|
||||||
from ota_manager import ota_manager
|
from ota_manager import ota_manager
|
||||||
@@ -2422,36 +2309,9 @@ class NetworkManager:
|
|||||||
item_is_high = False
|
item_is_high = False
|
||||||
|
|
||||||
if item:
|
if item:
|
||||||
msg_type, data_dict = item[:2]
|
msg_type, data_dict = item
|
||||||
sent_event = item[2] if len(item) > 2 else None
|
|
||||||
item_is_terminal = len(item) > 3 and item[3] == "terminal"
|
|
||||||
terminal_result = item[4] if item_is_terminal and len(item) > 4 else None
|
|
||||||
if (
|
|
||||||
isinstance(data_dict, dict)
|
|
||||||
and data_dict.get("cmd") == 1
|
|
||||||
and isinstance(data_dict.get("data"), dict)
|
|
||||||
):
|
|
||||||
shot_data = data_dict["data"]
|
|
||||||
self.logger.info(
|
|
||||||
f"[REPORT-TX] shot_id={shot_data.get('shot_id')}, "
|
|
||||||
f"target_class={shot_data.get('target_class')}, "
|
|
||||||
f"confidence={shot_data.get('target_class_confidence')}"
|
|
||||||
)
|
|
||||||
pkt = self._netcore.make_packet(msg_type, data_dict)
|
pkt = self._netcore.make_packet(msg_type, data_dict)
|
||||||
send_ok = (
|
if not self.tcp_send_raw(pkt):
|
||||||
self._tcp_send_terminal_raw(pkt)
|
|
||||||
if item_is_terminal
|
|
||||||
else self.tcp_send_raw(pkt)
|
|
||||||
)
|
|
||||||
if not send_ok:
|
|
||||||
if item_is_terminal:
|
|
||||||
if terminal_result is not None:
|
|
||||||
terminal_result["sent"] = False
|
|
||||||
if sent_event is not None:
|
|
||||||
sent_event.set()
|
|
||||||
break
|
|
||||||
if self._terminal_send_event.is_set():
|
|
||||||
continue
|
|
||||||
# 发送失败:将消息放回队首(队列满则丢弃)
|
# 发送失败:将消息放回队首(队列满则丢弃)
|
||||||
with self.get_queue_lock():
|
with self.get_queue_lock():
|
||||||
if item_is_high:
|
if item_is_high:
|
||||||
@@ -2466,10 +2326,6 @@ class NetworkManager:
|
|||||||
except:
|
except:
|
||||||
pass
|
pass
|
||||||
break
|
break
|
||||||
if sent_event is not None:
|
|
||||||
if terminal_result is not None:
|
|
||||||
terminal_result["sent"] = True
|
|
||||||
sent_event.set()
|
|
||||||
|
|
||||||
# 发送激光校准结果
|
# 发送激光校准结果
|
||||||
if logged_in:
|
if logged_in:
|
||||||
|
|||||||
@@ -10,7 +10,6 @@ import subprocess
|
|||||||
from logger_manager import logger_manager
|
from logger_manager import logger_manager
|
||||||
from maix import time as maix_time
|
from maix import time as maix_time
|
||||||
|
|
||||||
|
|
||||||
_INA226_PRESENT = None
|
_INA226_PRESENT = None
|
||||||
|
|
||||||
|
|
||||||
@@ -88,7 +87,7 @@ def get_current():
|
|||||||
"""
|
"""
|
||||||
读取电流(单位:mA)
|
读取电流(单位:mA)
|
||||||
当前电源板实测:正数表示放电,负数表示充电。
|
当前电源板实测:正数表示放电,负数表示充电。
|
||||||
|
|
||||||
INA226 电流计算公式:
|
INA226 电流计算公式:
|
||||||
Current = (Current Register Value) × Current_LSB
|
Current = (Current Register Value) × Current_LSB
|
||||||
Current_LSB = 0.001 × CALIBRATION_VALUE / 4096
|
Current_LSB = 0.001 × CALIBRATION_VALUE / 4096
|
||||||
@@ -121,10 +120,10 @@ def get_current():
|
|||||||
def is_charging(threshold_ma=10.0):
|
def is_charging(threshold_ma=10.0):
|
||||||
"""
|
"""
|
||||||
检测是否在充电(通过电流方向判断)
|
检测是否在充电(通过电流方向判断)
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
threshold_ma: 电流阈值(毫安),超过此值认为在充电,默认10mA
|
threshold_ma: 电流阈值(毫安),超过此值认为在充电,默认10mA
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
True: 正在充电
|
True: 正在充电
|
||||||
False: 未充电或读取失败
|
False: 未充电或读取失败
|
||||||
@@ -161,7 +160,7 @@ def voltage_to_percent(voltage):
|
|||||||
return 0
|
return 0
|
||||||
if v <= 0:
|
if v <= 0:
|
||||||
return 0
|
return 0
|
||||||
return int(int(_BATTERY_MONITOR.get_soc(v) * 10) / 10) # 截断而不是四舍五入
|
return int(int(_BATTERY_MONITOR.get_soc(v) * 10) / 10) # 截断而不是四舍五入
|
||||||
|
|
||||||
|
|
||||||
class BatteryMonitor:
|
class BatteryMonitor:
|
||||||
|
|||||||
+55
-14
@@ -8,7 +8,12 @@ from laser_manager import laser_manager
|
|||||||
from logger_manager import logger_manager
|
from logger_manager import logger_manager
|
||||||
from network import network_manager
|
from network import network_manager
|
||||||
from triangle_target import load_camera_from_xml, load_triangle_positions, try_triangle_scoring
|
from triangle_target import load_camera_from_xml, load_triangle_positions, try_triangle_scoring
|
||||||
from vision import estimate_distance, detect_circle_v3, enqueue_save_shot
|
from vision import (
|
||||||
|
estimate_distance,
|
||||||
|
detect_circle_v3,
|
||||||
|
enqueue_save_shot,
|
||||||
|
enqueue_save_raw_shot,
|
||||||
|
)
|
||||||
from maix import image, time
|
from maix import image, time
|
||||||
|
|
||||||
# 缓存相机标定与三角形位置,避免每次射箭重复读磁盘
|
# 缓存相机标定与三角形位置,避免每次射箭重复读磁盘
|
||||||
@@ -54,6 +59,7 @@ def analyze_shot(frame, laser_point=None):
|
|||||||
"""
|
"""
|
||||||
logger = logger_manager.logger
|
logger = logger_manager.logger
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
|
yellow_algorithm_ms = 0.0
|
||||||
|
|
||||||
# ── Step 1: 确定激光点 ────────────────────────────────────────────────────
|
# ── Step 1: 确定激光点 ────────────────────────────────────────────────────
|
||||||
laser_point_method = None
|
laser_point_method = None
|
||||||
@@ -69,7 +75,11 @@ def analyze_shot(frame, laser_point=None):
|
|||||||
logger.info(f"[算法] 使用校准值: {laser_manager.laser_point}")
|
logger.info(f"[算法] 使用校准值: {laser_manager.laser_point}")
|
||||||
else:
|
else:
|
||||||
# 动态模式:先做一次无激光点检测以估算距离,再推算激光点
|
# 动态模式:先做一次无激光点检测以估算距离,再推算激光点
|
||||||
_, _, _, _, best_radius1_temp, _ = detect_circle_v3(frame, None)
|
_t_yellow = time_std.perf_counter()
|
||||||
|
try:
|
||||||
|
_, _, _, _, best_radius1_temp, _ = detect_circle_v3(frame, None)
|
||||||
|
finally:
|
||||||
|
yellow_algorithm_ms += (time_std.perf_counter() - _t_yellow) * 1000.0
|
||||||
distance_m_first = estimate_distance(best_radius1_temp) if best_radius1_temp else None
|
distance_m_first = estimate_distance(best_radius1_temp) if best_radius1_temp else None
|
||||||
if distance_m_first and distance_m_first > 0:
|
if distance_m_first and distance_m_first > 0:
|
||||||
laser_point = laser_manager.calculate_laser_point_from_distance(distance_m_first)
|
laser_point = laser_manager.calculate_laser_point_from_distance(distance_m_first)
|
||||||
@@ -114,6 +124,7 @@ def analyze_shot(frame, laser_point=None):
|
|||||||
"laser_point": laser_point, "laser_point_method": laser_point_method,
|
"laser_point": laser_point, "laser_point_method": laser_point_method,
|
||||||
"offset_method": "yellow_ellipse" if ellipse_params else "yellow_circle",
|
"offset_method": "yellow_ellipse" if ellipse_params else "yellow_circle",
|
||||||
"distance_method": "yellow_radius",
|
"distance_method": "yellow_radius",
|
||||||
|
"yellow_algorithm_ms": float(yellow_algorithm_ms),
|
||||||
}
|
}
|
||||||
if yolo_roi_xyxy is not None:
|
if yolo_roi_xyxy is not None:
|
||||||
out["yolo_roi_xyxy"] = yolo_roi_xyxy
|
out["yolo_roi_xyxy"] = yolo_roi_xyxy
|
||||||
@@ -121,9 +132,12 @@ def analyze_shot(frame, laser_point=None):
|
|||||||
|
|
||||||
if not use_tri:
|
if not use_tri:
|
||||||
# 三角形未配置,直接跑圆形检测
|
# 三角形未配置,直接跑圆形检测
|
||||||
return _build_circle_result(
|
_t_yellow = time_std.perf_counter()
|
||||||
detect_circle_v3(frame, laser_point, img_cv=img_cv)
|
try:
|
||||||
)
|
cdata = detect_circle_v3(frame, laser_point, img_cv=img_cv)
|
||||||
|
finally:
|
||||||
|
yellow_algorithm_ms += (time_std.perf_counter() - _t_yellow) * 1000.0
|
||||||
|
return _build_circle_result(cdata)
|
||||||
|
|
||||||
# ── Step 4: 先独占跑三角形,超时或失败后再跑圆形(不与圆心并行,避免抢 CPU)──
|
# ── Step 4: 先独占跑三角形,超时或失败后再跑圆形(不与圆心并行,避免抢 CPU)──
|
||||||
roi_xyxy = None
|
roi_xyxy = None
|
||||||
@@ -276,6 +290,7 @@ def analyze_shot(frame, laser_point=None):
|
|||||||
"laser_point": laser_point, "laser_point_method": laser_point_method,
|
"laser_point": laser_point, "laser_point_method": laser_point_method,
|
||||||
"offset_method": tri.get("offset_method") or "triangle_homography",
|
"offset_method": tri.get("offset_method") or "triangle_homography",
|
||||||
"distance_method": tri.get("distance_method") or "pnp_triangle",
|
"distance_method": tri.get("distance_method") or "pnp_triangle",
|
||||||
|
"yellow_algorithm_ms": float(yellow_algorithm_ms),
|
||||||
"tri_markers": tri.get("markers", []),
|
"tri_markers": tri.get("markers", []),
|
||||||
"tri_markers_completed": tri.get("markers_completed", []),
|
"tri_markers_completed": tri.get("markers_completed", []),
|
||||||
"tri_homography": tri.get("homography"),
|
"tri_homography": tri.get("homography"),
|
||||||
@@ -296,7 +311,11 @@ def analyze_shot(frame, laser_point=None):
|
|||||||
|
|
||||||
# 三角形超时或失败 → 跑圆心;圆心跑完后再检查三角形是否已结束
|
# 三角形超时或失败 → 跑圆心;圆心跑完后再检查三角形是否已结束
|
||||||
try:
|
try:
|
||||||
cdata = detect_circle_v3(frame, laser_point, img_cv=img_cv)
|
_t_yellow = time_std.perf_counter()
|
||||||
|
try:
|
||||||
|
cdata = detect_circle_v3(frame, laser_point, img_cv=img_cv)
|
||||||
|
finally:
|
||||||
|
yellow_algorithm_ms += (time_std.perf_counter() - _t_yellow) * 1000.0
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"[CIRCLE] 圆形检测异常: {e}")
|
logger.error(f"[CIRCLE] 圆形检测异常: {e}")
|
||||||
cdata = (frame, None, None, None, None, None)
|
cdata = (frame, None, None, None, None, None)
|
||||||
@@ -321,14 +340,26 @@ def process_shot(adc_val):
|
|||||||
|
|
||||||
try:
|
try:
|
||||||
frame = camera_manager.read_frame()
|
frame = camera_manager.read_frame()
|
||||||
|
|
||||||
|
# Copy the untouched frame before any detection or drawing.
|
||||||
|
from shot_id_generator import shot_id_generator
|
||||||
|
shot_id = shot_id_generator.generate_id()
|
||||||
|
enqueue_save_raw_shot(frame, shot_id)
|
||||||
|
|
||||||
|
# 网络事件移到拍照之后,避免阻塞拍照
|
||||||
network_manager.safe_enqueue({"shoot_event": "start"}, msg_type=2, high=True)
|
network_manager.safe_enqueue({"shoot_event": "start"}, msg_type=2, high=True)
|
||||||
|
|
||||||
# 每箭只识别当前帧,不复用上一箭的靶规格结果。
|
# Classify only the current shot frame; never reuse a previous result.
|
||||||
target_class_result = None
|
target_class_result = None
|
||||||
|
yolo_target_ms = 0.0
|
||||||
try:
|
try:
|
||||||
from target_roi_yolo import try_get_target_class_from_yolo
|
from target_roi_yolo import try_get_target_class_from_yolo
|
||||||
|
|
||||||
target_class_result = try_get_target_class_from_yolo(frame, logger=logger)
|
_t_yolo_target = time_std.perf_counter()
|
||||||
|
try:
|
||||||
|
target_class_result = try_get_target_class_from_yolo(frame, logger=logger)
|
||||||
|
finally:
|
||||||
|
yolo_target_ms = (time_std.perf_counter() - _t_yolo_target) * 1000.0
|
||||||
if logger:
|
if logger:
|
||||||
logger.info(f"[YOLO-TARGET] 当前箭业务结果: {target_class_result}")
|
logger.info(f"[YOLO-TARGET] 当前箭业务结果: {target_class_result}")
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
@@ -358,6 +389,7 @@ def process_shot(adc_val):
|
|||||||
laser_point_method = analysis_result["laser_point_method"]
|
laser_point_method = analysis_result["laser_point_method"]
|
||||||
offset_method = analysis_result.get("offset_method", "yellow_circle")
|
offset_method = analysis_result.get("offset_method", "yellow_circle")
|
||||||
distance_method = analysis_result.get("distance_method", "yellow_radius")
|
distance_method = analysis_result.get("distance_method", "yellow_radius")
|
||||||
|
yellow_algorithm_ms = float(analysis_result.get("yellow_algorithm_ms", 0.0) or 0.0)
|
||||||
tri_markers = analysis_result.get("tri_markers", [])
|
tri_markers = analysis_result.get("tri_markers", [])
|
||||||
tri_markers_completed = analysis_result.get("tri_markers_completed", [])
|
tri_markers_completed = analysis_result.get("tri_markers_completed", [])
|
||||||
tri_homography = analysis_result.get("tri_homography")
|
tri_homography = analysis_result.get("tri_homography")
|
||||||
@@ -378,10 +410,6 @@ def process_shot(adc_val):
|
|||||||
if dx is None and dy is None and logger:
|
if dx is None and dy is None and logger:
|
||||||
logger.warning("[MAIN] 未检测到偏移量(三角形与圆形均失败),但会保存图像")
|
logger.warning("[MAIN] 未检测到偏移量(三角形与圆形均失败),但会保存图像")
|
||||||
|
|
||||||
# 生成射箭ID
|
|
||||||
from shot_id_generator import shot_id_generator
|
|
||||||
shot_id = shot_id_generator.generate_id()
|
|
||||||
|
|
||||||
if logger:
|
if logger:
|
||||||
logger.info(f"[MAIN] 射箭ID: {shot_id}")
|
logger.info(f"[MAIN] 射箭ID: {shot_id}")
|
||||||
|
|
||||||
@@ -411,7 +439,9 @@ def process_shot(adc_val):
|
|||||||
"r": 20.0, # 保留字段(服务端当前忽略,物理外环半径 cm)
|
"r": 20.0, # 保留字段(服务端当前忽略,物理外环半径 cm)
|
||||||
"target_class": target_label,
|
"target_class": target_label,
|
||||||
"target_class_confidence": (
|
"target_class_confidence": (
|
||||||
float(target_confidence) if target_confidence is not None else None
|
round(float(target_confidence), 2)
|
||||||
|
if target_confidence is not None
|
||||||
|
else None
|
||||||
),
|
),
|
||||||
"d": round((distance_m or 0.0) * 100),
|
"d": round((distance_m or 0.0) * 100),
|
||||||
"d_laser": round((laser_distance_m or 0.0) * 100),
|
"d_laser": round((laser_distance_m or 0.0) * 100),
|
||||||
@@ -423,6 +453,8 @@ def process_shot(adc_val):
|
|||||||
"target_y": float(y),
|
"target_y": float(y),
|
||||||
"offset_method": offset_method,
|
"offset_method": offset_method,
|
||||||
"distance_method": distance_method,
|
"distance_method": distance_method,
|
||||||
|
"yellow_algorithm_ms": round(yellow_algorithm_ms, 2),
|
||||||
|
"yolo_target_ms": round(float(yolo_target_ms), 2),
|
||||||
}
|
}
|
||||||
|
|
||||||
if ellipse_params:
|
if ellipse_params:
|
||||||
@@ -439,6 +471,13 @@ def process_shot(adc_val):
|
|||||||
inner_data["ellipse_center_x"] = None
|
inner_data["ellipse_center_x"] = None
|
||||||
inner_data["ellipse_center_y"] = None
|
inner_data["ellipse_center_y"] = None
|
||||||
|
|
||||||
|
# 记录这组 inner_data 即将进入上报队列的本地时间,精确到毫秒。
|
||||||
|
upload_time_ms = int(time_std.time() * 1000)
|
||||||
|
upload_time_sec, upload_time_millis = divmod(upload_time_ms, 1000)
|
||||||
|
inner_data["upload_time"] = (
|
||||||
|
time_std.strftime("%Y-%m-%d %H:%M:%S", time_std.localtime(upload_time_sec))
|
||||||
|
+ f".{upload_time_millis:03d}"
|
||||||
|
)
|
||||||
report_data = {"cmd": 1, "data": inner_data}
|
report_data = {"cmd": 1, "data": inner_data}
|
||||||
if logger:
|
if logger:
|
||||||
logger.info(
|
logger.info(
|
||||||
@@ -549,6 +588,7 @@ def process_shot(adc_val):
|
|||||||
laser_manager.flash_laser(config.FLASH_LASER_DURATION_MS)
|
laser_manager.flash_laser(config.FLASH_LASER_DURATION_MS)
|
||||||
|
|
||||||
# 保存图像(异步队列,与 main.py 一致)
|
# 保存图像(异步队列,与 main.py 一致)
|
||||||
|
_force_save = (dx is None and dy is None) and getattr(config, "SAVE_IMAGE_ON_FAILURE", False)
|
||||||
enqueue_save_shot(
|
enqueue_save_shot(
|
||||||
result_img,
|
result_img,
|
||||||
center,
|
center,
|
||||||
@@ -558,8 +598,9 @@ def process_shot(adc_val):
|
|||||||
(x, y),
|
(x, y),
|
||||||
distance_m,
|
distance_m,
|
||||||
shot_id=shot_id,
|
shot_id=shot_id,
|
||||||
photo_dir=config.PHOTO_DIR if config.SAVE_IMAGE_ENABLED else None,
|
photo_dir=config.PHOTO_DIR if (config.SAVE_IMAGE_ENABLED or _force_save) else None,
|
||||||
yolo_roi_xyxy=yolo_roi_xyxy if draw_yolo_roi else None,
|
yolo_roi_xyxy=yolo_roi_xyxy if draw_yolo_roi else None,
|
||||||
|
force_save=_force_save,
|
||||||
)
|
)
|
||||||
|
|
||||||
if logger:
|
if logger:
|
||||||
|
|||||||
+14
-5
@@ -126,10 +126,19 @@ def _get_detector(model_path: str):
|
|||||||
return _detector_by_path[model_path]
|
return _detector_by_path[model_path]
|
||||||
try:
|
try:
|
||||||
from maix import nn
|
from maix import nn
|
||||||
except ImportError:
|
except Exception:
|
||||||
return None
|
return None
|
||||||
_detector_by_path[model_path] = nn.YOLOv5(model=model_path, dual_buff=False)
|
# YOLO is an optional capability. A broken/incompatible model must not
|
||||||
return _detector_by_path[model_path]
|
# abort boot (especially before the OTA rollback check).
|
||||||
|
try:
|
||||||
|
detector = nn.YOLOv5(model=model_path, dual_buff=False)
|
||||||
|
except Exception:
|
||||||
|
# Cache the failure to avoid retrying a broken native load every frame.
|
||||||
|
# reset_yolo_detector_cache() clears this after a model replacement.
|
||||||
|
_detector_by_path[model_path] = None
|
||||||
|
return None
|
||||||
|
_detector_by_path[model_path] = detector
|
||||||
|
return detector
|
||||||
|
|
||||||
|
|
||||||
def preload_yolo_detector(logger=None):
|
def preload_yolo_detector(logger=None):
|
||||||
@@ -285,7 +294,7 @@ def _normalize_objs(objs):
|
|||||||
|
|
||||||
|
|
||||||
def _det_obj_score(o):
|
def _det_obj_score(o):
|
||||||
"""兼容 Maix YOLO 不同版本的置信度字段。"""
|
"""Return confidence across supported Maix YOLO result formats."""
|
||||||
for key in ("score", "confidence", "conf", "prob"):
|
for key in ("score", "confidence", "conf", "prob"):
|
||||||
if hasattr(o, key):
|
if hasattr(o, key):
|
||||||
try:
|
try:
|
||||||
@@ -301,7 +310,7 @@ def _det_obj_score(o):
|
|||||||
|
|
||||||
|
|
||||||
def try_get_target_class_from_yolo(maix_frame, logger=None):
|
def try_get_target_class_from_yolo(maix_frame, logger=None):
|
||||||
"""识别当前帧的 20/40 靶规格,失败返回 None。"""
|
"""Classify the current target as 20cm or 40cm; return None if unknown."""
|
||||||
try:
|
try:
|
||||||
import config as cfg
|
import config as cfg
|
||||||
except Exception:
|
except Exception:
|
||||||
|
|||||||
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,144 +0,0 @@
|
|||||||
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()
|
|
||||||
@@ -154,11 +154,11 @@ def detect_circle_v3(frame, laser_point=None):
|
|||||||
max_r = max(red_radius, yellow_radius)
|
max_r = max(red_radius, yellow_radius)
|
||||||
size_ratio = min_r / max_r if max_r > 0 else 0
|
size_ratio = min_r / max_r if max_r > 0 else 0
|
||||||
print(f"Debug -> 圆心距={distance:.1f}(阈值={max_distance:.1f}), "
|
print(f"Debug -> 圆心距={distance:.1f}(阈值={max_distance:.1f}), "
|
||||||
f"大小比={size_ratio:.2f}(阈值=0.5), "
|
f"大小比={size_ratio:.2f}(阈值=0.4), "
|
||||||
f"距离OK={distance < max_distance}, 大小OK={size_ratio > 0.5}")
|
f"距离OK={distance < max_distance}, 大小OK={size_ratio >= 0.4}")
|
||||||
|
|
||||||
# 允许红圈在黄圈外侧或内侧,只要大小相近(较小/较大 >= 0.5)
|
# 允许红圈在黄圈外侧或内侧,只要大小相近(较小/较大 >= 0.5)
|
||||||
if distance < max_distance and size_ratio > 0.5:
|
if distance < max_distance and size_ratio >= 0.4:
|
||||||
found_valid_red = True
|
found_valid_red = True
|
||||||
print(
|
print(
|
||||||
f"[target] -> 找到匹配的红圈: 黄心({yellow_center}), 红心({red_center}), 距离:{distance:.1f}, 黄半径:{yellow_radius}, 红半径:{red_radius}")
|
f"[target] -> 找到匹配的红圈: 黄心({yellow_center}), 红心({red_center}), 距离:{distance:.1f}, 黄半径:{yellow_radius}, 红半径:{red_radius}")
|
||||||
@@ -598,7 +598,7 @@ if __name__ == "__main__":
|
|||||||
|
|
||||||
# 1. 设置要测试的图片路径
|
# 1. 设置要测试的图片路径
|
||||||
# 建议将图片放在与脚本同级目录,或者使用绝对路径
|
# 建议将图片放在与脚本同级目录,或者使用绝对路径
|
||||||
TARGET_IMAGE = "/root/phot/None_314_258_0_0041.bmp"
|
TARGET_IMAGE = "/root/phot/shot_1830921_0_no_target.jpg"
|
||||||
|
|
||||||
TARGET_DIR = "/root/phot" # 修改为你想要读取的目录路径
|
TARGET_DIR = "/root/phot" # 修改为你想要读取的目录路径
|
||||||
|
|
||||||
|
|||||||
@@ -1,59 +0,0 @@
|
|||||||
#!/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,108 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
"""Run from MaixVision on PC to inspect the box's live 20/40 YOLO output."""
|
||||||
|
|
||||||
|
import os
|
||||||
|
|
||||||
|
from maix import app, camera, display, image, nn, time
|
||||||
|
|
||||||
|
|
||||||
|
# This file is sent to /tmp/maixpy_run by MaixVision. Keep the model path
|
||||||
|
# absolute so the script uses the model already installed on the box.
|
||||||
|
MODEL_PATH = "/maixapp/apps/t11/model_317181.mud"
|
||||||
|
CAMERA_WIDTH = 640
|
||||||
|
CAMERA_HEIGHT = 480
|
||||||
|
CONF_TH = 0.65
|
||||||
|
IOU_TH = 0.45
|
||||||
|
|
||||||
|
|
||||||
|
def _flatten_objects(raw):
|
||||||
|
if raw is None:
|
||||||
|
return []
|
||||||
|
if isinstance(raw, (list, tuple)):
|
||||||
|
result = []
|
||||||
|
for item in raw:
|
||||||
|
if isinstance(item, (list, tuple)):
|
||||||
|
result.extend(_flatten_objects(item))
|
||||||
|
else:
|
||||||
|
result.append(item)
|
||||||
|
return result
|
||||||
|
return [raw]
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
if not os.path.isfile(MODEL_PATH):
|
||||||
|
raise FileNotFoundError("model not found on box: " + MODEL_PATH)
|
||||||
|
|
||||||
|
detector = nn.YOLOv5(model=MODEL_PATH, dual_buff=False)
|
||||||
|
cam = camera.Camera(CAMERA_WIDTH, CAMERA_HEIGHT)
|
||||||
|
disp = display.Display()
|
||||||
|
|
||||||
|
labels = tuple(str(label) for label in detector.labels)
|
||||||
|
print("[YOLO] model:", MODEL_PATH)
|
||||||
|
print("[YOLO] labels:", labels)
|
||||||
|
print("[YOLO] conf=%.2f iou=%.2f" % (CONF_TH, IOU_TH))
|
||||||
|
|
||||||
|
fps = 0.0
|
||||||
|
frame_count = 0
|
||||||
|
last_log_ms = time.ticks_ms()
|
||||||
|
|
||||||
|
while not app.need_exit():
|
||||||
|
loop_start_ms = time.ticks_ms()
|
||||||
|
img = cam.read()
|
||||||
|
|
||||||
|
detect_start_ms = time.ticks_ms()
|
||||||
|
raw = detector.detect(img, conf_th=CONF_TH, iou_th=IOU_TH)
|
||||||
|
detect_ms = max(0, time.ticks_diff(time.ticks_ms(), detect_start_ms))
|
||||||
|
objects = _flatten_objects(raw)
|
||||||
|
|
||||||
|
candidates = []
|
||||||
|
for obj in objects:
|
||||||
|
class_id = int(obj.class_id)
|
||||||
|
score = float(obj.score)
|
||||||
|
label = labels[class_id] if 0 <= class_id < len(labels) else "unknown"
|
||||||
|
color = image.COLOR_GREEN if label in ("20", "40") else image.COLOR_RED
|
||||||
|
|
||||||
|
img.draw_rect(obj.x, obj.y, obj.w, obj.h, color=color)
|
||||||
|
img.draw_string(
|
||||||
|
obj.x,
|
||||||
|
max(0, obj.y - 16),
|
||||||
|
"%scm %.2f" % (label, score),
|
||||||
|
color=color,
|
||||||
|
)
|
||||||
|
if label in ("20", "40"):
|
||||||
|
candidates.append((score, label))
|
||||||
|
|
||||||
|
loop_ms = max(1, time.ticks_diff(time.ticks_ms(), loop_start_ms))
|
||||||
|
instant_fps = 1000.0 / float(loop_ms)
|
||||||
|
fps = instant_fps if frame_count == 0 else fps * 0.9 + instant_fps * 0.1
|
||||||
|
|
||||||
|
if candidates:
|
||||||
|
best_score, best_label = max(candidates, key=lambda item: item[0])
|
||||||
|
status = "TARGET %scm %.2f" % (best_label, best_score)
|
||||||
|
status_color = image.COLOR_GREEN
|
||||||
|
else:
|
||||||
|
status = "TARGET UNKNOWN"
|
||||||
|
status_color = image.COLOR_RED
|
||||||
|
|
||||||
|
img.draw_string(5, 5, status, color=status_color)
|
||||||
|
img.draw_string(
|
||||||
|
5,
|
||||||
|
25,
|
||||||
|
"infer=%dms fps=%.1f boxes=%d" % (detect_ms, fps, len(objects)),
|
||||||
|
color=image.COLOR_YELLOW,
|
||||||
|
)
|
||||||
|
disp.show(img)
|
||||||
|
|
||||||
|
frame_count += 1
|
||||||
|
now_ms = time.ticks_ms()
|
||||||
|
if time.ticks_diff(now_ms, last_log_ms) >= 1000:
|
||||||
|
print(
|
||||||
|
"[YOLO] %s infer=%dms fps=%.1f boxes=%d"
|
||||||
|
% (status, detect_ms, fps, len(objects))
|
||||||
|
)
|
||||||
|
last_log_ms = now_ms
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
@@ -1,139 +0,0 @@
|
|||||||
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()
|
|
||||||
@@ -0,0 +1,184 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
"""Offline baseline for traditional target-paper detection.
|
||||||
|
|
||||||
|
Dataset format: sibling .txt files use YOLO boxes and classes.txt maps ids
|
||||||
|
(the supplied dataset uses 0=40, 1=20, 2=10). This intentionally simple
|
||||||
|
baseline uses grayscale segmentation and contour geometry; it is useful as a
|
||||||
|
reference before adding more specialized black-triangle grouping.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import csv
|
||||||
|
import glob
|
||||||
|
import itertools
|
||||||
|
import os
|
||||||
|
|
||||||
|
import cv2
|
||||||
|
import numpy as np
|
||||||
|
|
||||||
|
|
||||||
|
def detect_white_papers(image: np.ndarray) -> list[tuple[int, int, int, int]]:
|
||||||
|
gray = cv2.cvtColor(image, cv2.COLOR_BGR2GRAY)
|
||||||
|
h, w = gray.shape[:2]
|
||||||
|
mask = cv2.inRange(gray, 120, 255)
|
||||||
|
mask = cv2.morphologyEx(mask, cv2.MORPH_CLOSE, np.ones((9, 9), np.uint8))
|
||||||
|
mask = cv2.morphologyEx(mask, cv2.MORPH_OPEN, np.ones((5, 5), np.uint8))
|
||||||
|
contours, _ = cv2.findContours(mask, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE)
|
||||||
|
out = []
|
||||||
|
for contour in contours:
|
||||||
|
x, y, bw, bh = cv2.boundingRect(contour)
|
||||||
|
area = float(bw * bh)
|
||||||
|
if area < 0.05 * w * h or min(bw, bh) < 80:
|
||||||
|
continue
|
||||||
|
fill = cv2.contourArea(contour) / max(area, 1.0)
|
||||||
|
aspect = bw / max(float(bh), 1.0)
|
||||||
|
if fill >= 0.45 and 0.4 <= aspect <= 2.5:
|
||||||
|
out.append((x, y, x + bw, y + bh))
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def detect_black_triangle_papers(image: np.ndarray):
|
||||||
|
"""Infer paper boxes from the four small black corner marks."""
|
||||||
|
gray = cv2.cvtColor(image, cv2.COLOR_BGR2GRAY)
|
||||||
|
mask = cv2.inRange(gray, 0, 100)
|
||||||
|
mask = cv2.morphologyEx(mask, cv2.MORPH_OPEN, np.ones((2, 2), np.uint8))
|
||||||
|
contours, _ = cv2.findContours(mask, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE)
|
||||||
|
points = []
|
||||||
|
for contour in contours:
|
||||||
|
x, y, bw, bh = cv2.boundingRect(contour)
|
||||||
|
area = cv2.contourArea(contour)
|
||||||
|
vertices = cv2.approxPolyDP(contour, 0.08 * cv2.arcLength(contour, True), True)
|
||||||
|
if 60 <= area <= 400 and 8 <= bw <= 24 and 8 <= bh <= 24:
|
||||||
|
if 3 <= len(vertices) <= 5 and 0.5 <= bw / max(bh, 1) <= 2.0:
|
||||||
|
points.append((x + bw / 2.0, y + bh / 2.0))
|
||||||
|
candidates = []
|
||||||
|
for group in itertools.combinations(points, 4):
|
||||||
|
xs = sorted(p[0] for p in group)
|
||||||
|
ys = sorted(p[1] for p in group)
|
||||||
|
span_x, span_y = xs[-1] - xs[0], ys[-1] - ys[0]
|
||||||
|
if span_x < 50 or span_y < 50 or not 0.45 < span_x / span_y < 1.5:
|
||||||
|
continue
|
||||||
|
corners = ((xs[0], ys[0]), (xs[-1], ys[0]),
|
||||||
|
(xs[0], ys[-1]), (xs[-1], ys[-1]))
|
||||||
|
error = max(min(np.hypot(p[0] - c[0], p[1] - c[1]) for c in corners)
|
||||||
|
for p in group) / max(span_x, span_y)
|
||||||
|
if error > 0.22:
|
||||||
|
continue
|
||||||
|
ex, ey = 0.12 * span_x, 0.12 * span_y
|
||||||
|
candidates.append((xs[0] - ex, ys[0] - ey,
|
||||||
|
xs[-1] + ex, ys[-1] + ey, error))
|
||||||
|
# A colored target ring supplies an independent center check. Hough is
|
||||||
|
# deliberately low-cost here because it runs only on the already small
|
||||||
|
# candidate list's source frame.
|
||||||
|
hsv = cv2.cvtColor(image, cv2.COLOR_BGR2HSV)
|
||||||
|
color = cv2.inRange(hsv, (0, 70, 45), (179, 255, 255))
|
||||||
|
color = cv2.morphologyEx(color, cv2.MORPH_OPEN, np.ones((5, 5), np.uint8))
|
||||||
|
ring_centers = []
|
||||||
|
for contour in cv2.findContours(color, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE)[0]:
|
||||||
|
area = cv2.contourArea(contour)
|
||||||
|
if area < 150:
|
||||||
|
continue
|
||||||
|
moments = cv2.moments(contour)
|
||||||
|
if moments["m00"]:
|
||||||
|
ring_centers.append((moments["m10"] / moments["m00"], moments["m01"] / moments["m00"]))
|
||||||
|
checked = []
|
||||||
|
for box in candidates:
|
||||||
|
if not ring_centers:
|
||||||
|
checked.append(box)
|
||||||
|
continue
|
||||||
|
x0, y0, x1, y1, err = box
|
||||||
|
inside = any(x0 - .15 * (x1 - x0) <= cx <= x1 + .15 * (x1 - x0)
|
||||||
|
and y0 - .15 * (y1 - y0) <= cy <= y1 + .15 * (y1 - y0)
|
||||||
|
for cx, cy in ring_centers)
|
||||||
|
if inside:
|
||||||
|
checked.append(box)
|
||||||
|
return sorted(checked, key=lambda x: x[-1])
|
||||||
|
|
||||||
|
|
||||||
|
def iou(a, b):
|
||||||
|
x0, y0 = max(a[0], b[0]), max(a[1], b[1])
|
||||||
|
x1, y1 = min(a[2], b[2]), min(a[3], b[3])
|
||||||
|
inter = max(0, x1 - x0) * max(0, y1 - y0)
|
||||||
|
aa = max(0, a[2] - a[0]) * max(0, a[3] - a[1])
|
||||||
|
bb = max(0, b[2] - b[0]) * max(0, b[3] - b[1])
|
||||||
|
return inter / max(aa + bb - inter, 1)
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
ap = argparse.ArgumentParser()
|
||||||
|
ap.add_argument("dataset", help="directory containing jpg and YOLO txt files")
|
||||||
|
ap.add_argument("--iou", type=float, default=0.5)
|
||||||
|
ap.add_argument("--out", default="traditional_eval_results.csv",
|
||||||
|
help="CSV output path; relative paths are next to the dataset")
|
||||||
|
ap.add_argument("--vis-dir", default="traditional_eval_images",
|
||||||
|
help="directory for annotated result images; empty disables")
|
||||||
|
args = ap.parse_args()
|
||||||
|
stats = {0: [0, 0], 1: [0, 0]}
|
||||||
|
rows = []
|
||||||
|
# OpenCV on some Windows builds cannot decode non-ASCII filenames. Work
|
||||||
|
# relative to the dataset directory so the supplied Chinese path is safe.
|
||||||
|
dataset = os.path.abspath(args.dataset)
|
||||||
|
os.chdir(dataset)
|
||||||
|
# cwd is now the dataset, so a relative output avoids Windows console
|
||||||
|
# encoding issues with the Chinese parent path.
|
||||||
|
vis_dir = args.vis_dir if args.vis_dir else ""
|
||||||
|
if vis_dir:
|
||||||
|
os.makedirs(vis_dir, exist_ok=True)
|
||||||
|
files = glob.glob(os.path.join("**", "*.jpg"), recursive=True)
|
||||||
|
for image_path in files:
|
||||||
|
label_path = os.path.splitext(image_path)[0] + ".txt"
|
||||||
|
if not os.path.isfile(label_path):
|
||||||
|
continue
|
||||||
|
image = cv2.imread(image_path)
|
||||||
|
if image is None:
|
||||||
|
continue
|
||||||
|
h, w = image.shape[:2]
|
||||||
|
predictions = detect_black_triangle_papers(image)
|
||||||
|
vis = image.copy()
|
||||||
|
for p in predictions:
|
||||||
|
cv2.rectangle(vis, (int(p[0]), int(p[1])), (int(p[2]), int(p[3])), (0, 255, 255), 2)
|
||||||
|
for line in open(label_path, encoding="utf-8", errors="ignore"):
|
||||||
|
z = line.split()
|
||||||
|
if len(z) < 5 or int(float(z[0])) not in stats:
|
||||||
|
continue
|
||||||
|
cls, cx, cy, bw, bh = int(float(z[0])), *map(float, z[1:5])
|
||||||
|
truth = (int((cx - bw / 2) * w), int((cy - bh / 2) * h),
|
||||||
|
int((cx + bw / 2) * w), int((cy + bh / 2) * h))
|
||||||
|
best = max((iou(truth, p) for p in predictions), default=0.0)
|
||||||
|
best_box = max(predictions, key=lambda p: iou(truth, p), default=())
|
||||||
|
stats[cls][0] += 1
|
||||||
|
stats[cls][1] += int(best >= args.iou)
|
||||||
|
rows.append({
|
||||||
|
"image": image_path,
|
||||||
|
"class_id": cls,
|
||||||
|
"truth_xyxy": ",".join(map(str, truth[:4])),
|
||||||
|
"pred_xyxy": ",".join(map(str, best_box[:4])) if best_box else "",
|
||||||
|
"iou": f"{best:.4f}",
|
||||||
|
"pass": int(best >= args.iou),
|
||||||
|
})
|
||||||
|
color = (0, 255, 0) if best >= args.iou else (0, 0, 255)
|
||||||
|
cv2.rectangle(vis, truth[:2], truth[2:4], color, 2)
|
||||||
|
cv2.putText(vis, f"GT {cls} IoU {best:.2f}",
|
||||||
|
(truth[0], max(16, truth[1] - 4)),
|
||||||
|
cv2.FONT_HERSHEY_SIMPLEX, 0.45, color, 1, cv2.LINE_AA)
|
||||||
|
if vis_dir:
|
||||||
|
name = os.path.splitext(os.path.basename(image_path))[0] + "_result.jpg"
|
||||||
|
cv2.imwrite(os.path.join(vis_dir, name), vis)
|
||||||
|
total = sum(v[0] for v in stats.values())
|
||||||
|
good = sum(v[1] for v in stats.values())
|
||||||
|
print(f"paper objects: {good}/{total} = {good / max(total, 1):.2%} (IoU >= {args.iou})")
|
||||||
|
for cls, (n, ok) in stats.items():
|
||||||
|
print(f"class {cls}: {ok}/{n} = {ok / max(n, 1):.2%}")
|
||||||
|
out_path = args.out if os.path.isabs(args.out) else os.path.join(dataset, args.out)
|
||||||
|
with open(out_path, "w", newline="", encoding="utf-8-sig") as fp:
|
||||||
|
writer = csv.DictWriter(fp, fieldnames=("image", "class_id", "truth_xyxy",
|
||||||
|
"pred_xyxy", "iou", "pass"))
|
||||||
|
writer.writeheader()
|
||||||
|
writer.writerows(rows)
|
||||||
|
print(f"details csv: {out_path}")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
+9
-8
@@ -29,11 +29,12 @@
|
|||||||
# 2.15.16 修复wifi连接问题
|
# 2.15.16 修复wifi连接问题
|
||||||
# 2.15.17 修复wifi连接问题
|
# 2.15.17 修复wifi连接问题
|
||||||
# 2.15.18 wifi连接成功重新登录
|
# 2.15.18 wifi连接成功重新登录
|
||||||
# 2.15.20 加了充电关机,激光也同时关闭
|
# 2.16.4 优化射箭延迟
|
||||||
# 2.15.21 测试4g 扩大了缓存池和改了心跳时间
|
# 2.17.0 yolo标靶类别识别
|
||||||
# 2.15.22 修复了4g网络和wifi切换问题
|
# 2.17.1 26-08-19 17:39 压力传感修改 增量方式
|
||||||
# 2.15.23 合并充电关机与稳定版网络修复
|
|
||||||
# 2.15.24 空改测试
|
# 2.17.2 26-08-24 17:56 靶纸识别模型更替
|
||||||
# 2.15.25 修复整合后关机失败和ota格式更新问题
|
|
||||||
# 2.15.26
|
# 2.17.3 26-08-25 9:57 原图拍摄开关
|
||||||
# 2.15.33 26-8-12 14:03 修改充4g电关机时间 修复切换网络卡住bug
|
|
||||||
|
# 2.17.4 26-08-25 14:57 模型修改
|
||||||
+1
-1
@@ -4,6 +4,6 @@
|
|||||||
应用版本号
|
应用版本号
|
||||||
每次 OTA 更新时,只需要更新这个文件中的版本号
|
每次 OTA 更新时,只需要更新这个文件中的版本号
|
||||||
"""
|
"""
|
||||||
VERSION = '2.15.36'
|
VERSION = '2.17.18'
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -631,7 +631,7 @@ def detect_circle_v3(frame, laser_point=None, img_cv=None):
|
|||||||
min_r = min(rc["radius"], yellow_radius)
|
min_r = min(rc["radius"], yellow_radius)
|
||||||
max_r = max(rc["radius"], yellow_radius)
|
max_r = max(rc["radius"], yellow_radius)
|
||||||
size_ratio = min_r / max_r if max_r > 0 else 0
|
size_ratio = min_r / max_r if max_r > 0 else 0
|
||||||
if dist_centers < max_dist and size_ratio >= 0.3:
|
if dist_centers < max_dist and size_ratio >= 0.4:
|
||||||
if logger:
|
if logger:
|
||||||
logger.info(f"[target] -> 找到匹配的红圈: 黄心({yellow_center}), "
|
logger.info(f"[target] -> 找到匹配的红圈: 黄心({yellow_center}), "
|
||||||
f"红心({rc['center']}), 距离:{dist_centers:.1f}, "
|
f"红心({rc['center']}), 距离:{dist_centers:.1f}, "
|
||||||
@@ -797,12 +797,12 @@ def estimate_pixel(physical_distance_cm, target_distance_m):
|
|||||||
|
|
||||||
def _save_shot_image_impl(img_cv, center, radius, method, ellipse_params,
|
def _save_shot_image_impl(img_cv, center, radius, method, ellipse_params,
|
||||||
laser_point, distance_m, shot_id=None, photo_dir=None,
|
laser_point, distance_m, shot_id=None, photo_dir=None,
|
||||||
yolo_roi_xyxy=None):
|
yolo_roi_xyxy=None, force_save=False):
|
||||||
"""
|
"""
|
||||||
内部实现:在 img_cv (numpy HWC RGB) 上绘制标注并保存。
|
内部实现:在 img_cv (numpy HWC RGB) 上绘制标注并保存。
|
||||||
由 save_shot_image(同步)和存图 worker(异步)调用。
|
由 save_shot_image(同步)和存图 worker(异步)调用。
|
||||||
"""
|
"""
|
||||||
if not config.SAVE_IMAGE_ENABLED:
|
if not config.SAVE_IMAGE_ENABLED and not force_save:
|
||||||
return None
|
return None
|
||||||
if photo_dir is None:
|
if photo_dir is None:
|
||||||
photo_dir = config.PHOTO_DIR
|
photo_dir = config.PHOTO_DIR
|
||||||
@@ -908,7 +908,12 @@ def _save_worker_loop():
|
|||||||
item = _save_queue.get()
|
item = _save_queue.get()
|
||||||
if item is None:
|
if item is None:
|
||||||
break
|
break
|
||||||
_save_shot_image_impl(*item)
|
if isinstance(item, dict) and item.get("kind") == "raw":
|
||||||
|
_save_raw_image_impl(
|
||||||
|
item["img_cv"], item["shot_id"], item["photo_dir"]
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
_save_shot_image_impl(*item)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger = logger_manager.logger
|
logger = logger_manager.logger
|
||||||
if logger:
|
if logger:
|
||||||
@@ -936,13 +941,64 @@ def start_save_shot_worker():
|
|||||||
logger.info("[VISION] 存图 worker 线程已启动")
|
logger.info("[VISION] 存图 worker 线程已启动")
|
||||||
|
|
||||||
|
|
||||||
|
def _save_raw_image_impl(img_cv, shot_id, photo_dir):
|
||||||
|
"""保存相机完整原始帧,不添加任何检测标注。"""
|
||||||
|
logger = logger_manager.logger
|
||||||
|
try:
|
||||||
|
os.makedirs(photo_dir, exist_ok=True)
|
||||||
|
filename = os.path.join(photo_dir, f"shot_{shot_id}_raw.jpg")
|
||||||
|
image.cv2image(img_cv, False, False).save(filename)
|
||||||
|
prune_old_images_in_dir(
|
||||||
|
photo_dir,
|
||||||
|
getattr(config, "RAW_IMAGE_MAX_IMAGES", config.MAX_IMAGES),
|
||||||
|
logger,
|
||||||
|
"[VISION-RAW]",
|
||||||
|
)
|
||||||
|
if logger:
|
||||||
|
logger.info(f"[VISION-RAW] 已保存纯原图: {filename}")
|
||||||
|
return filename
|
||||||
|
except Exception as e:
|
||||||
|
if logger:
|
||||||
|
logger.error(f"[VISION-RAW] 保存纯原图失败: {e}")
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def enqueue_save_raw_shot(frame, shot_id, photo_dir=None):
|
||||||
|
"""立即复制相机帧并异步保存,避免后续识别和绘图修改原图。"""
|
||||||
|
if not getattr(config, "SAVE_RAW_IMAGE_ENABLED", False):
|
||||||
|
return
|
||||||
|
if photo_dir is None:
|
||||||
|
photo_dir = getattr(
|
||||||
|
config, "RAW_IMAGE_DIR", os.path.join(config.PHOTO_DIR, "raw")
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
img_copy = np.copy(image.image2cv(frame, False, False))
|
||||||
|
_save_queue.put_nowait(
|
||||||
|
{
|
||||||
|
"kind": "raw",
|
||||||
|
"img_cv": img_copy,
|
||||||
|
"shot_id": shot_id,
|
||||||
|
"photo_dir": photo_dir,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
except queue.Full:
|
||||||
|
logger = logger_manager.logger
|
||||||
|
if logger:
|
||||||
|
logger.warning("[VISION-RAW] 存图队列已满,跳过本次纯原图保存")
|
||||||
|
except Exception as e:
|
||||||
|
logger = logger_manager.logger
|
||||||
|
if logger:
|
||||||
|
logger.error(f"[VISION-RAW] 复制纯原图失败: {e}")
|
||||||
|
|
||||||
|
|
||||||
def enqueue_save_shot(result_img, center, radius, method, ellipse_params,
|
def enqueue_save_shot(result_img, center, radius, method, ellipse_params,
|
||||||
laser_point, distance_m, shot_id=None, photo_dir=None,
|
laser_point, distance_m, shot_id=None, photo_dir=None,
|
||||||
yolo_roi_xyxy=None):
|
yolo_roi_xyxy=None, force_save=False):
|
||||||
"""
|
"""
|
||||||
将存图任务放入队列,由 worker 异步保存。主线程传入 result_img 的复制,不阻塞。
|
将存图任务放入队列,由 worker 异步保存。主线程传入 result_img 的复制,不阻塞。
|
||||||
|
force_save=True 时,忽略 SAVE_IMAGE_ENABLED 配置强制保存(用于检测失败时的调试图像)。
|
||||||
"""
|
"""
|
||||||
if not config.SAVE_IMAGE_ENABLED:
|
if not config.SAVE_IMAGE_ENABLED and not force_save:
|
||||||
return
|
return
|
||||||
if photo_dir is None:
|
if photo_dir is None:
|
||||||
photo_dir = config.PHOTO_DIR
|
photo_dir = config.PHOTO_DIR
|
||||||
@@ -965,6 +1021,7 @@ def enqueue_save_shot(result_img, center, radius, method, ellipse_params,
|
|||||||
shot_id,
|
shot_id,
|
||||||
photo_dir,
|
photo_dir,
|
||||||
yolo_roi_xyxy,
|
yolo_roi_xyxy,
|
||||||
|
force_save,
|
||||||
)
|
)
|
||||||
try:
|
try:
|
||||||
_save_queue.put_nowait(task)
|
_save_queue.put_nowait(task)
|
||||||
@@ -976,12 +1033,12 @@ def enqueue_save_shot(result_img, center, radius, method, ellipse_params,
|
|||||||
|
|
||||||
def save_shot_image(result_img, center, radius, method, ellipse_params,
|
def save_shot_image(result_img, center, radius, method, ellipse_params,
|
||||||
laser_point, distance_m, shot_id=None, photo_dir=None,
|
laser_point, distance_m, shot_id=None, photo_dir=None,
|
||||||
yolo_roi_xyxy=None):
|
yolo_roi_xyxy=None, force_save=False):
|
||||||
"""
|
"""
|
||||||
保存射击图像(带标注)。同步调用,会阻塞。
|
保存射击图像(带标注)。同步调用,会阻塞。
|
||||||
主流程建议使用 enqueue_save_shot;此处保留供校准、测试等场景使用。
|
主流程建议使用 enqueue_save_shot;此处保留供校准、测试等场景使用。
|
||||||
"""
|
"""
|
||||||
if not config.SAVE_IMAGE_ENABLED:
|
if not config.SAVE_IMAGE_ENABLED and not force_save:
|
||||||
return None
|
return None
|
||||||
if photo_dir is None:
|
if photo_dir is None:
|
||||||
photo_dir = config.PHOTO_DIR
|
photo_dir = config.PHOTO_DIR
|
||||||
@@ -998,6 +1055,7 @@ def save_shot_image(result_img, center, radius, method, ellipse_params,
|
|||||||
shot_id,
|
shot_id,
|
||||||
photo_dir,
|
photo_dir,
|
||||||
yolo_roi_xyxy,
|
yolo_roi_xyxy,
|
||||||
|
force_save,
|
||||||
)
|
)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger = logger_manager.logger
|
logger = logger_manager.logger
|
||||||
|
|||||||
@@ -541,7 +541,7 @@ class WiFiManager:
|
|||||||
|
|
||||||
def start_quality_monitor(self, network_type_callback, on_poor_quality_callback):
|
def start_quality_monitor(self, network_type_callback, on_poor_quality_callback):
|
||||||
"""
|
"""
|
||||||
启动 WiFi 质量后台监测线程(每 5 秒检查 STA 关联状态和 RSSI)
|
启动 WiFi 质量后台监测线程(每 5 秒测量一次 RTT 和 RSSI)
|
||||||
只在 WiFi 连接时运行,不影响业务发送性能
|
只在 WiFi 连接时运行,不影响业务发送性能
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
@@ -549,20 +549,15 @@ class WiFiManager:
|
|||||||
on_poor_quality_callback: WiFi质量差时的回调函数
|
on_poor_quality_callback: WiFi质量差时的回调函数
|
||||||
"""
|
"""
|
||||||
with self._wifi_quality_lock:
|
with self._wifi_quality_lock:
|
||||||
current_thread = self._wifi_quality_monitor_thread
|
if self._wifi_quality_monitor_thread is not None and self._wifi_quality_monitor_thread.is_alive():
|
||||||
current_stop_event = self._wifi_quality_stop_event
|
|
||||||
if (current_thread is not None and current_thread.is_alive()
|
|
||||||
and not current_stop_event.is_set()):
|
|
||||||
self.logger.warning("[WiFi Monitor] 监测线程已在运行")
|
self.logger.warning("[WiFi Monitor] 监测线程已在运行")
|
||||||
return
|
return
|
||||||
|
|
||||||
self._network_type_callback = network_type_callback
|
self._network_type_callback = network_type_callback
|
||||||
self._on_poor_quality_callback = on_poor_quality_callback
|
self._on_poor_quality_callback = on_poor_quality_callback
|
||||||
stop_event = threading.Event()
|
self._wifi_quality_stop_event.clear()
|
||||||
self._wifi_quality_stop_event = stop_event
|
|
||||||
self._wifi_quality_monitor_thread = threading.Thread(
|
self._wifi_quality_monitor_thread = threading.Thread(
|
||||||
target=self._quality_monitor_loop,
|
target=self._quality_monitor_loop,
|
||||||
args=(stop_event,),
|
|
||||||
daemon=True,
|
daemon=True,
|
||||||
name="wifi_quality_monitor"
|
name="wifi_quality_monitor"
|
||||||
)
|
)
|
||||||
@@ -573,14 +568,13 @@ class WiFiManager:
|
|||||||
"""停止 WiFi 质量监测线程"""
|
"""停止 WiFi 质量监测线程"""
|
||||||
with self._wifi_quality_lock:
|
with self._wifi_quality_lock:
|
||||||
t = self._wifi_quality_monitor_thread
|
t = self._wifi_quality_monitor_thread
|
||||||
stop_event = self._wifi_quality_stop_event
|
|
||||||
if t is None:
|
if t is None:
|
||||||
return
|
return
|
||||||
if not t.is_alive():
|
if not t.is_alive():
|
||||||
self._wifi_quality_monitor_thread = None
|
self._wifi_quality_monitor_thread = None
|
||||||
return
|
return
|
||||||
|
|
||||||
stop_event.set()
|
self._wifi_quality_stop_event.set()
|
||||||
try:
|
try:
|
||||||
t.join(timeout=2.0)
|
t.join(timeout=2.0)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
@@ -594,41 +588,37 @@ class WiFiManager:
|
|||||||
self._wifi_quality_monitor_thread = None
|
self._wifi_quality_monitor_thread = None
|
||||||
self.logger.info("[WiFi Monitor] 已停止后台监测线程")
|
self.logger.info("[WiFi Monitor] 已停止后台监测线程")
|
||||||
|
|
||||||
def _quality_monitor_loop(self, stop_event):
|
def _quality_monitor_loop(self):
|
||||||
"""
|
"""
|
||||||
WiFi 质量监测循环(后台线程)
|
WiFi 质量监测循环(后台线程)
|
||||||
每 5 秒检查 STA 关联状态和 RSSI,发现断链或质量差则触发切换
|
每 5 秒测量一次 RTT 和 RSSI,发现质量差则触发切换
|
||||||
"""
|
"""
|
||||||
while not stop_event.is_set():
|
while not self._wifi_quality_stop_event.is_set():
|
||||||
try:
|
try:
|
||||||
# 只在 WiFi 连接时才测量
|
# 只在 WiFi 连接时才测量
|
||||||
network_type = self._network_type_callback()
|
network_type = self._network_type_callback()
|
||||||
if network_type == "wifi" and self._wifi_socket:
|
if network_type == "wifi" and self._wifi_socket:
|
||||||
# RTT 测量当前禁用;STA 关联状态用于判断物理 WiFi 链路是否仍存在。
|
# # 测量 RTT(1 个样本,快速测量)
|
||||||
# 不能把禁用的 RTT 伪装成 0ms,否则关闭热点后会一直被判为正常。
|
# rtt_ms, reachable = self._measure_wifi_tcp_rtt_ms(
|
||||||
reachable = self.is_sta_associated()
|
# self._server_ip, self._server_port,
|
||||||
rtt_ms = None
|
# samples=1, per_sample_timeout_ms=600
|
||||||
|
# )
|
||||||
|
|
||||||
# 获取 RSSI
|
# 获取 RSSI
|
||||||
rssi_dbm = self._get_wifi_rssi_dbm()
|
rssi_dbm = self._get_wifi_rssi_dbm()
|
||||||
|
|
||||||
# 更新缓存
|
# 更新缓存
|
||||||
self._last_wifi_rtt_ms = rtt_ms
|
# 不使用 RTT 测量
|
||||||
|
rtt_ms = 0
|
||||||
|
reachable = True
|
||||||
|
self._last_wifi_rtt_ms = rtt_ms if reachable else None
|
||||||
self._last_wifi_rssi_dbm = rssi_dbm
|
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"
|
_rssi_s = f"{rssi_dbm:.0f}" if rssi_dbm is not None else "n/a"
|
||||||
self.logger.debug(
|
self.logger.debug(f"[WiFi Monitor] - RTT={rtt_ms:.0f}ms, RSSI={_rssi_s}dBm")
|
||||||
f"[WiFi Monitor] - associated={reachable}, RTT={_rtt_s}, RSSI={_rssi_s}dBm"
|
|
||||||
)
|
|
||||||
|
|
||||||
# 判断质量是否差(切换前做 2 次快速复测,防止瞬时抖动)
|
# 判断质量是否差(切换前做 2 次快速复测,防止瞬时抖动)
|
||||||
def _is_bad_now(_reachable, _rtt, _rssi):
|
def _is_bad_now(_reachable, _rtt, _rssi):
|
||||||
if not _reachable:
|
if (not _reachable) or (_rtt is None) or (_rtt == float("inf")):
|
||||||
return True
|
|
||||||
# RTT 未启用时不参与质量判断;链路状态仍由 STA 关联保证。
|
|
||||||
if _rtt is None:
|
|
||||||
return False
|
|
||||||
if _rtt == float("inf"):
|
|
||||||
return True
|
return True
|
||||||
return self._is_wifi_quality_bad(_rtt, _rssi)
|
return self._is_wifi_quality_bad(_rtt, _rssi)
|
||||||
|
|
||||||
@@ -637,10 +627,14 @@ class WiFiManager:
|
|||||||
self.logger.warning("[WiFi Monitor] 质量差,切换前快速重试 2 次(每次间隔1秒)")
|
self.logger.warning("[WiFi Monitor] 质量差,切换前快速重试 2 次(每次间隔1秒)")
|
||||||
|
|
||||||
for retry_idx in range(2):
|
for retry_idx in range(2):
|
||||||
if stop_event.wait(1.0):
|
time.sleep_ms(1000)
|
||||||
return
|
# 不使用 RTT 测量
|
||||||
reachable2 = self.is_sta_associated()
|
rtt2 = 0
|
||||||
rtt2 = None
|
reachable2 = True
|
||||||
|
# rtt2, reachable2 = self._measure_wifi_tcp_rtt_ms(
|
||||||
|
# self._server_ip, self._server_port,
|
||||||
|
# samples=1, per_sample_timeout_ms=600
|
||||||
|
# )
|
||||||
rssi2 = self._get_wifi_rssi_dbm()
|
rssi2 = self._get_wifi_rssi_dbm()
|
||||||
|
|
||||||
# 更新缓存,便于外部查看最新状态
|
# 更新缓存,便于外部查看最新状态
|
||||||
@@ -649,10 +643,14 @@ class WiFiManager:
|
|||||||
|
|
||||||
bad2 = _is_bad_now(reachable2, rtt2, rssi2)
|
bad2 = _is_bad_now(reachable2, rtt2, rssi2)
|
||||||
try:
|
try:
|
||||||
_rtt_disp = f"{rtt2:.0f}ms" if rtt2 is not None else "n/a"
|
_rtt_disp = (
|
||||||
|
rtt2
|
||||||
|
if rtt2 is not None and rtt2 != float("inf")
|
||||||
|
else -1
|
||||||
|
)
|
||||||
self.logger.info(
|
self.logger.info(
|
||||||
f"[WiFi Monitor] 复测{retry_idx+1}/2: reachable={reachable2}, "
|
f"[WiFi Monitor] 复测{retry_idx+1}/2: reachable={reachable2}, "
|
||||||
f"rtt={_rtt_disp}, rssi={rssi2}, bad={bad2}"
|
f"rtt={_rtt_disp:.0f}ms, rssi={rssi2}, bad={bad2}"
|
||||||
)
|
)
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
@@ -667,7 +665,7 @@ class WiFiManager:
|
|||||||
self._on_poor_quality_callback()
|
self._on_poor_quality_callback()
|
||||||
|
|
||||||
# 休眠 5 秒
|
# 休眠 5 秒
|
||||||
stop_event.wait(5.0)
|
time.sleep(5)
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
self.logger.error(f"[WiFi Monitor] 监测异常:{e}")
|
self.logger.error(f"[WiFi Monitor] 监测异常:{e}")
|
||||||
|
|||||||
Reference in New Issue
Block a user