Compare commits
10
Commits
06994c5905
...
2.17.0
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e67c410325 | ||
|
|
0c8ab1508f | ||
|
|
d30c432143 | ||
|
|
c5338ccac7 | ||
|
|
231937afba | ||
|
|
70aa072164 | ||
|
|
42026d43e5 | ||
|
|
8a83deddd3 | ||
|
|
6a1d3fe2bd | ||
|
|
1fee464924 |
Vendored
+3
@@ -0,0 +1,3 @@
|
||||
{
|
||||
"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.
@@ -1,6 +1,6 @@
|
||||
id: t11
|
||||
name: t11
|
||||
version: 2.16.3
|
||||
version: 2.17.15
|
||||
author: t11
|
||||
icon: ''
|
||||
desc: t11
|
||||
@@ -18,6 +18,8 @@ files:
|
||||
- laser_manager.py
|
||||
- logger_manager.py
|
||||
- main.py
|
||||
- model_317828.cvimodel
|
||||
- model_317828.mud
|
||||
- network.py
|
||||
- ota_curl.sh
|
||||
- ota_manager.py
|
||||
|
||||
+26
-1
@@ -8,6 +8,15 @@ import threading
|
||||
import config
|
||||
from logger_manager import logger_manager
|
||||
|
||||
_USE_CV = False
|
||||
try:
|
||||
import cv2
|
||||
import numpy as np
|
||||
from maix import image as _maix_image
|
||||
_USE_CV = True
|
||||
except ImportError:
|
||||
pass
|
||||
|
||||
|
||||
class CameraManager:
|
||||
"""相机管理器(单例)"""
|
||||
@@ -101,7 +110,23 @@ class CameraManager:
|
||||
with self._camera_lock:
|
||||
if self._camera is None:
|
||||
self.init_camera()
|
||||
return self._camera.read()
|
||||
frame = self._camera.read()
|
||||
if frame is not None and _USE_CV:
|
||||
try:
|
||||
v_flip = getattr(config, 'CAMERA_V_FLIP', False)
|
||||
h_mirror = getattr(config, 'CAMERA_H_MIRROR', False)
|
||||
if v_flip or h_mirror:
|
||||
img_cv = _maix_image.image2cv(frame, False, False)
|
||||
if v_flip and h_mirror:
|
||||
img_cv = cv2.flip(img_cv, -1)
|
||||
elif v_flip:
|
||||
img_cv = cv2.flip(img_cv, 0)
|
||||
elif h_mirror:
|
||||
img_cv = cv2.flip(img_cv, 1)
|
||||
frame = _maix_image.cv2image(img_cv, False, False)
|
||||
except Exception:
|
||||
pass
|
||||
return frame
|
||||
|
||||
def show(self, image):
|
||||
"""
|
||||
|
||||
@@ -15,6 +15,8 @@ LOCAL_FILENAME = APP_DIR + "/main_tmp.py"
|
||||
# 相机初始化分辨率(CameraManager / main.py 使用)
|
||||
CAMERA_WIDTH = 640
|
||||
CAMERA_HEIGHT = 480
|
||||
CAMERA_V_FLIP = True # 摄像头垂直翻转(上下颠倒时设为 True)
|
||||
CAMERA_H_MIRROR = True # 摄像头水平镜像(左右反了时设为 True)
|
||||
|
||||
# 三角形检测缩图比例:默认按相机最长边缩到 1/2(性能更稳;可按需调整)
|
||||
# 取值范围建议 (0.25 ~ 1.0];1.0 表示不缩图
|
||||
@@ -234,10 +236,10 @@ TRIANGLE_BLACKHAT_KERNEL_FRAC = 0.018 # 核大小 ≈ min(h,w)*frac,取奇数
|
||||
# ── YOLO(NPU) 靶环 ROI → 裁剪后再跑三角形(减小 CPU 处理面积)──────────────────
|
||||
# 日志里 net_in=W×H 来自 .mud 模型(det.input_width/height),不是这里配置的。
|
||||
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 会偏小。
|
||||
TRIANGLE_YOLO_RING_CLASS_IDS = (0,)
|
||||
TRIANGLE_YOLO_CONF_TH = 0.7
|
||||
TRIANGLE_YOLO_CONF_TH = 0.9
|
||||
TRIANGLE_YOLO_IOU_TH = 0.45
|
||||
# YOLO 首次/临界帧可能在高阈值下 0 框;启用后仅在 0 候选时用较低阈值重试一次。
|
||||
# 后续仍会经过 min_box_side、ROI aspect、三角形几何校验,避免直接放大假阳性。
|
||||
@@ -262,6 +264,16 @@ TRIANGLE_SAMPLE_PATCH_HALF_PX = 2
|
||||
# 开机阶段预加载 YOLO detector;detect 使用 dual_buff=False,避免返回上一帧结果。
|
||||
TRIANGLE_YOLO_PRELOAD_ON_BOOT = False
|
||||
|
||||
# YOLO target size classification: class 0=20cm, class 1=40cm.
|
||||
TARGET_CLASS_YOLO_ENABLE = True
|
||||
TARGET_CLASS_YOLO_MODEL_PATH = APP_DIR + "/model_317828.mud"
|
||||
TARGET_CLASS_YOLO_LABELS = (20, 40)
|
||||
TARGET_CLASS_YOLO_CONF_TH = 0.66
|
||||
TARGET_CLASS_YOLO_IOU_TH = 0.45
|
||||
TARGET_CLASS_YOLO_RETRY_ON_EMPTY = False
|
||||
TARGET_CLASS_YOLO_RETRY_CONF_TH = 0.25
|
||||
TARGET_CLASS_YOLO_PRELOAD_ON_BOOT = True
|
||||
|
||||
# ── 第二段 YOLO:仅在 Stage1 裁切出的靶环图上推理(与合成 stage2 训练数据一致)→ 子框内传统算法取直角点 ──
|
||||
# Stage1 靶环裁切内如何找黑三角标记(对比耗时时可切换):
|
||||
# "yolo" — 调 Stage2 黑三角模型得子框,再子框内传统提取(需 TRIANGLE_BLACK_YOLO_ENABLE=True)。
|
||||
@@ -316,14 +328,17 @@ LOG_QUEUE_MAXSIZE = 10000 # 日志队列上限
|
||||
MAX_CMD_THREADS = 10 # 并发命令线程上限(防止服务器下发命令时无限创建线程)
|
||||
|
||||
# ==================== 图像保存配置 ====================
|
||||
SAVE_IMAGE_ENABLED = False # 是否保存图像(True=保存,False=不保存)
|
||||
SAVE_IMAGE_ON_FAILURE = True # 检测失败时是否强制保存图像(供调试测试用)
|
||||
SAVE_IMAGE_ENABLED = True # 是否保存图像(True=保存,False=不保存)
|
||||
SAVE_IMAGE_ON_FAILURE = False # 检测失败时是否强制保存图像(供调试测试用)
|
||||
PHOTO_DIR = "/root/phot" # 照片存储目录
|
||||
MAX_IMAGES = 1000
|
||||
SAVE_RAW_IMAGE_ENABLED = True # 额外保存完整原始帧(不画框、不画点、不裁剪)
|
||||
RAW_IMAGE_DIR = PHOTO_DIR + "/raw"
|
||||
RAW_IMAGE_MAX_IMAGES = MAX_IMAGES
|
||||
# Stage2 调试目录(默认 PHOTO_DIR/stage2_roi)内 JPEG 最多保留张数;None 表示与 MAX_IMAGES 相同
|
||||
TRIANGLE_BLACK_YOLO_STAGE2_ROI_MAX_IMAGES = None
|
||||
|
||||
SHOW_CAMERA_PHOTO_WHILE_SHOOTING = False # 是否在拍摄时显示摄像头图像(True=显示,False=不显示),建议在连着USB测试过程中打开
|
||||
SHOW_CAMERA_PHOTO_WHILE_SHOOTING = True # 是否在拍摄时显示摄像头图像(True=显示,False=不显示),建议在连着USB测试过程中打开
|
||||
|
||||
# ==================== OTA配置 ====================
|
||||
MAX_BACKUPS = 5
|
||||
|
||||
@@ -132,6 +132,7 @@ def cmd_str():
|
||||
sync_system_time_from_4g()
|
||||
|
||||
# 2.1 WiFi 热点配网兜底:仅当 STA 与 4G 均不可用时起 AP + HTTP;提交后删 /boot/wifi.ap、建 wifi.sta 并 reboot
|
||||
_ota_pending_path = f"{config.APP_DIR}/ota_pending.json"
|
||||
try:
|
||||
from wifi_config_httpd import maybe_start_wifi_ap_fallback
|
||||
|
||||
@@ -162,15 +163,21 @@ def cmd_str():
|
||||
and _loc_black == "yolo"
|
||||
and bool(getattr(config, "TRIANGLE_BLACK_YOLO_PRELOAD_ON_BOOT", True))
|
||||
)
|
||||
_preload_yolo = _preload_yolo or _need_black_preload
|
||||
if _preload_yolo:
|
||||
_need_target_preload = (
|
||||
bool(getattr(config, "TARGET_CLASS_YOLO_ENABLE", False))
|
||||
and bool(getattr(config, "TARGET_CLASS_YOLO_PRELOAD_ON_BOOT", True))
|
||||
)
|
||||
_preload_yolo = _preload_yolo or _need_black_preload or _need_target_preload
|
||||
if _preload_yolo and not os.path.exists(_ota_pending_path):
|
||||
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:
|
||||
if logger:
|
||||
logger.warning(f"[YOLO-ROI] 启动预加载异常(不影响后续射箭): {e}")
|
||||
|
||||
# 3. 启动时检查:是否需要恢复备份
|
||||
pending_path = f"{config.APP_DIR}/ota_pending.json"
|
||||
pending_path = _ota_pending_path
|
||||
if os.path.exists(pending_path):
|
||||
try:
|
||||
with open(pending_path, 'r', encoding='utf-8') as f:
|
||||
@@ -246,7 +253,11 @@ def cmd_str():
|
||||
network_manager.read_device_id()
|
||||
|
||||
# 5. 创建照片存储目录(如果启用图像保存或检测失败时强制保存)
|
||||
if config.SAVE_IMAGE_ENABLED or getattr(config, "SAVE_IMAGE_ON_FAILURE", False):
|
||||
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
|
||||
if photo_dir not in os.listdir("/root"):
|
||||
try:
|
||||
@@ -278,12 +289,13 @@ def cmd_str():
|
||||
logger.info("系统准备完成...")
|
||||
|
||||
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
|
||||
peak_adc_val = 0 # 当前周期内的压力峰值
|
||||
# 气压采样:减少日志频率(每 N 个点输出一条),避免 logger.debug 拖慢采样
|
||||
PRESSURE_BATCH_SIZE = 100
|
||||
|
||||
@@ -373,22 +385,16 @@ def cmd_str():
|
||||
pressure_max = adc_val
|
||||
if len(pressure_buf) >= PRESSURE_BATCH_SIZE:
|
||||
_flush_pressure_buf("batch")
|
||||
# 峰值检测:压力从峰值下降时触发,确保捕获到最大冲击时刻
|
||||
if adc_val > peak_adc_val:
|
||||
peak_adc_val = adc_val # 更新峰值
|
||||
if (peak_adc_val >= config.ADC_TRIGGER_THRESHOLD
|
||||
and adc_val < peak_adc_val
|
||||
and last_adc_val >= peak_adc_val):
|
||||
# 封顶后下降沿触发:peak是最大值,当前值开始下降,且上次值还在peak位置
|
||||
# 突变增量检测:压力增量大于300时触发
|
||||
# 触发后需等气压降到触发值以下才重新检测增量
|
||||
if adc_val < trigger_adc_val :
|
||||
enable_check = True
|
||||
if (adc_val - last_adc_val) > 500 and enable_check:
|
||||
hardware_manager.start_idle_timer() # 重新计时
|
||||
diff_ms = current_time - last_adc_trigger
|
||||
if diff_ms < 3000:
|
||||
peak_adc_val = 0 # 去抖期间重置峰值
|
||||
time.sleep_ms(5)
|
||||
continue
|
||||
last_adc_trigger = current_time
|
||||
peak_adc_val = 0 # 触发后重置峰值
|
||||
# 触发前先把缓存刷出来,避免波形被长耗时处理截断
|
||||
trigger_adc_val = adc_val # 记录触发时的气压值
|
||||
last_adc_val = adc_val # 更新基准值,防止连续增量误触发
|
||||
enable_check = False
|
||||
_flush_pressure_buf("before_trigger")
|
||||
|
||||
try:
|
||||
@@ -407,7 +413,7 @@ def cmd_str():
|
||||
camera_manager.show(camera_manager.read_frame())
|
||||
except Exception as e:
|
||||
pass
|
||||
time.sleep_ms(5)
|
||||
time.sleep_ms(1)
|
||||
last_adc_val = adc_val
|
||||
|
||||
except Exception as e:
|
||||
|
||||
Binary file not shown.
@@ -1,7 +1,7 @@
|
||||
|
||||
[basic]
|
||||
type = cvimodel
|
||||
model = model_270139.cvimodel
|
||||
model = model_317189.cvimodel
|
||||
|
||||
[extra]
|
||||
model_type = yolov5
|
||||
@@ -9,5 +9,5 @@ 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 = 黑三角和圆环
|
||||
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.
@@ -0,0 +1,13 @@
|
||||
|
||||
[basic]
|
||||
type = cvimodel
|
||||
model = model_317423.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 = 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]
|
||||
type = cvimodel
|
||||
model = model_270820.cvimodel
|
||||
model = model_317828.cvimodel
|
||||
|
||||
[extra]
|
||||
model_type = yolov5
|
||||
@@ -9,5 +9,5 @@ input_type = rgb
|
||||
mean = 0, 0, 0
|
||||
scale = 0.00392156862745098, 0.00392156862745098, 0.00392156862745098
|
||||
anchors = 10, 13, 16, 30, 33, 23, 30, 61, 62, 45, 59, 119, 116, 90, 156, 198, 373, 326
|
||||
labels = triangle
|
||||
labels = 20, 40
|
||||
|
||||
@@ -5,10 +5,11 @@
|
||||
提供电压、电流监测和充电状态检测
|
||||
"""
|
||||
import config
|
||||
import os
|
||||
import subprocess
|
||||
from logger_manager import logger_manager
|
||||
from maix import time as maix_time
|
||||
|
||||
|
||||
_INA226_PRESENT = None
|
||||
|
||||
|
||||
@@ -85,7 +86,7 @@ def get_bus_voltage():
|
||||
def get_current():
|
||||
"""
|
||||
读取电流(单位:mA)
|
||||
正数表示充电,负数表示放电
|
||||
当前电源板实测:正数表示放电,负数表示充电。
|
||||
|
||||
INA226 电流计算公式:
|
||||
Current = (Current Register Value) × Current_LSB
|
||||
@@ -96,13 +97,13 @@ def get_current():
|
||||
return 0.0
|
||||
raw = read_register(config.REG_CURRENT)
|
||||
# INA226 电流寄存器是16位有符号整数
|
||||
# 最高位是符号位:0=正(充电),1=负(放电)
|
||||
# 最高位是符号位;电流方向含义取决于电源板的采样电阻接线方向。
|
||||
# 计算 Current_LSB(根据 CALIBRATION_VALUE)
|
||||
current_lsb = 0.001 * config.CALIBRATION_VALUE / 4096 # 单位:A
|
||||
# 处理有符号数:如果最高位为1,转换为负数
|
||||
if raw & 0x8000: # 最高位为1,表示负数(放电)
|
||||
if raw & 0x8000:
|
||||
signed_raw = raw - 0x10000 # 转换为有符号整数
|
||||
else: # 最高位为0,表示正数(充电)
|
||||
else:
|
||||
signed_raw = raw
|
||||
# 转换为毫安
|
||||
current_ma = signed_raw * current_lsb * 1000
|
||||
@@ -129,7 +130,7 @@ def is_charging(threshold_ma=10.0):
|
||||
"""
|
||||
try:
|
||||
current = get_current()
|
||||
is_charge = current > threshold_ma
|
||||
is_charge = current < -abs(float(threshold_ma))
|
||||
return is_charge
|
||||
except Exception as e:
|
||||
logger = logger_manager.logger
|
||||
@@ -159,7 +160,7 @@ def voltage_to_percent(voltage):
|
||||
return 0
|
||||
if v <= 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:
|
||||
|
||||
+49
-5
@@ -8,7 +8,12 @@ from laser_manager import laser_manager
|
||||
from logger_manager import logger_manager
|
||||
from network import network_manager
|
||||
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
|
||||
|
||||
# 缓存相机标定与三角形位置,避免每次射箭重复读磁盘
|
||||
@@ -322,9 +327,26 @@ def process_shot(adc_val):
|
||||
try:
|
||||
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)
|
||||
|
||||
# Classify only the current shot frame; never reuse a previous result.
|
||||
target_class_result = None
|
||||
try:
|
||||
from target_roi_yolo import try_get_target_class_from_yolo
|
||||
|
||||
target_class_result = try_get_target_class_from_yolo(frame, logger=logger)
|
||||
if logger:
|
||||
logger.info(f"[YOLO-TARGET] 当前箭业务结果: {target_class_result}")
|
||||
except Exception as exc:
|
||||
if logger:
|
||||
logger.warning(f"[YOLO-TARGET] 当前箭分类失败,按未知处理: {exc}")
|
||||
|
||||
# 调用算法分析
|
||||
analysis_result = analyze_shot(frame)
|
||||
|
||||
@@ -368,10 +390,6 @@ def process_shot(adc_val):
|
||||
if dx is None and dy is None and logger:
|
||||
logger.warning("[MAIN] 未检测到偏移量(三角形与圆形均失败),但会保存图像")
|
||||
|
||||
# 生成射箭ID
|
||||
from shot_id_generator import shot_id_generator
|
||||
shot_id = shot_id_generator.generate_id()
|
||||
|
||||
if logger:
|
||||
logger.info(f"[MAIN] 射箭ID: {shot_id}")
|
||||
|
||||
@@ -384,11 +402,25 @@ def process_shot(adc_val):
|
||||
srv_y = round(float(dy), 4) if dy is not None else 200.0
|
||||
|
||||
# 构造上报数据
|
||||
target_label = (
|
||||
target_class_result.get("label")
|
||||
if isinstance(target_class_result, dict)
|
||||
else None
|
||||
)
|
||||
target_confidence = (
|
||||
target_class_result.get("confidence")
|
||||
if isinstance(target_class_result, dict)
|
||||
else None
|
||||
)
|
||||
inner_data = {
|
||||
"shot_id": shot_id,
|
||||
"x": srv_x,
|
||||
"y": srv_y,
|
||||
"r": 20.0, # 保留字段(服务端当前忽略,物理外环半径 cm)
|
||||
"target_class": target_label,
|
||||
"target_class_confidence": (
|
||||
float(target_confidence) if target_confidence is not None else None
|
||||
),
|
||||
"d": round((distance_m or 0.0) * 100),
|
||||
"d_laser": round((laser_distance_m or 0.0) * 100),
|
||||
"d_laser_quality": laser_signal_quality,
|
||||
@@ -415,7 +447,19 @@ def process_shot(adc_val):
|
||||
inner_data["ellipse_center_x"] = 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}
|
||||
if logger:
|
||||
logger.info(
|
||||
f"[REPORT-TARGET] enqueue shot_id={shot_id}, "
|
||||
f"target_class={target_label}, confidence={target_confidence}"
|
||||
)
|
||||
network_manager.safe_enqueue(report_data, msg_type=2, high=True)
|
||||
|
||||
# 数据上报后再画标注,不干扰检测阶段的原始画面
|
||||
|
||||
+155
-4
@@ -89,6 +89,29 @@ def _stage2_roi_crop_save_worker(
|
||||
_detector_by_path = {}
|
||||
|
||||
|
||||
def _resolve_model_path(model_path: str):
|
||||
"""Resolve a model in either the installed app or MaixVision run directory."""
|
||||
model_path = (model_path or "").strip()
|
||||
if model_path and os.path.isfile(model_path):
|
||||
return model_path
|
||||
if not model_path:
|
||||
return ""
|
||||
name = os.path.basename(model_path)
|
||||
module_dir = os.path.dirname(os.path.abspath(__file__))
|
||||
candidates = (
|
||||
os.path.join(module_dir, name),
|
||||
os.path.join(module_dir, "test", name),
|
||||
os.path.join("/tmp/maixpy_run", name),
|
||||
os.path.join("/tmp/maixpy_run", "test", name),
|
||||
os.path.join(os.getcwd(), name),
|
||||
os.path.join(os.getcwd(), "test", name),
|
||||
)
|
||||
for candidate in candidates:
|
||||
if os.path.isfile(candidate):
|
||||
return candidate
|
||||
return model_path
|
||||
|
||||
|
||||
def reset_yolo_detector_cache():
|
||||
"""切换模型路径时可调用(通常不必)。"""
|
||||
global _detector_by_path
|
||||
@@ -103,10 +126,19 @@ def _get_detector(model_path: str):
|
||||
return _detector_by_path[model_path]
|
||||
try:
|
||||
from maix import nn
|
||||
except ImportError:
|
||||
except Exception:
|
||||
return None
|
||||
_detector_by_path[model_path] = nn.YOLOv5(model=model_path, dual_buff=False)
|
||||
return _detector_by_path[model_path]
|
||||
# YOLO is an optional capability. A broken/incompatible model must not
|
||||
# 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):
|
||||
@@ -175,6 +207,23 @@ def preload_yolo_detector(logger=None):
|
||||
% (_loc_black,)
|
||||
)
|
||||
|
||||
if bool(getattr(cfg, "TARGET_CLASS_YOLO_ENABLE", False)) and bool(
|
||||
getattr(cfg, "TARGET_CLASS_YOLO_PRELOAD_ON_BOOT", True)
|
||||
):
|
||||
class_model_path = _resolve_model_path(
|
||||
getattr(cfg, "TARGET_CLASS_YOLO_MODEL_PATH", "") or ""
|
||||
)
|
||||
class_detector = _get_detector(class_model_path)
|
||||
if class_detector is None:
|
||||
if logger:
|
||||
logger.warning(
|
||||
f"[YOLO-TARGET] 预加载失败:无法加载模型 {class_model_path}"
|
||||
)
|
||||
else:
|
||||
ok = True
|
||||
if logger:
|
||||
logger.info(f"[YOLO-TARGET] 靶规格模型已预加载: {class_model_path}")
|
||||
|
||||
return ok
|
||||
|
||||
|
||||
@@ -206,8 +255,10 @@ def _det_obj_class_id(o):
|
||||
if v is None:
|
||||
continue
|
||||
try:
|
||||
if callable(v):
|
||||
v = v()
|
||||
return int(float(v))
|
||||
except (TypeError, ValueError):
|
||||
except (TypeError, ValueError, AttributeError):
|
||||
continue
|
||||
return None
|
||||
|
||||
@@ -242,6 +293,106 @@ def _normalize_objs(objs):
|
||||
return out
|
||||
|
||||
|
||||
def _det_obj_score(o):
|
||||
"""Return confidence across supported Maix YOLO result formats."""
|
||||
for key in ("score", "confidence", "conf", "prob"):
|
||||
if hasattr(o, key):
|
||||
try:
|
||||
value = getattr(o, key)
|
||||
if callable(value):
|
||||
value = value()
|
||||
value = float(value)
|
||||
if value == value:
|
||||
return value
|
||||
except (TypeError, ValueError, AttributeError):
|
||||
pass
|
||||
return 0.0
|
||||
|
||||
|
||||
def try_get_target_class_from_yolo(maix_frame, logger=None):
|
||||
"""Classify the current target as 20cm or 40cm; return None if unknown."""
|
||||
try:
|
||||
import config as cfg
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
if not bool(getattr(cfg, "TARGET_CLASS_YOLO_ENABLE", False)):
|
||||
return None
|
||||
model_path = _resolve_model_path(
|
||||
getattr(cfg, "TARGET_CLASS_YOLO_MODEL_PATH", "") or ""
|
||||
)
|
||||
if not os.path.isfile(model_path):
|
||||
if logger:
|
||||
logger.warning(f"[YOLO-TARGET] 模型文件不存在: {model_path}")
|
||||
return None
|
||||
detector = _get_detector(model_path)
|
||||
if detector is None:
|
||||
if logger:
|
||||
logger.warning("[YOLO-TARGET] 无法加载 nn.YOLOv5")
|
||||
return None
|
||||
|
||||
conf_th = float(getattr(cfg, "TARGET_CLASS_YOLO_CONF_TH", 0.5))
|
||||
iou_th = float(getattr(cfg, "TARGET_CLASS_YOLO_IOU_TH", 0.45))
|
||||
labels = getattr(cfg, "TARGET_CLASS_YOLO_LABELS", (20, 40))
|
||||
if isinstance(labels, str):
|
||||
labels = tuple(x.strip() for x in labels.split(",") if x.strip())
|
||||
labels = tuple(labels)
|
||||
|
||||
def _detect(threshold):
|
||||
try:
|
||||
raw = detector.detect(maix_frame, conf_th=threshold, iou_th=iou_th)
|
||||
except Exception as exc:
|
||||
if logger:
|
||||
logger.warning(f"[YOLO-TARGET] detect 异常: {exc}")
|
||||
return []
|
||||
return _normalize_objs(raw if raw is not None else [])
|
||||
|
||||
def _candidates(objs):
|
||||
found = []
|
||||
for obj in objs:
|
||||
class_id = _det_obj_class_id(obj)
|
||||
if class_id is None or class_id < 0 or class_id >= len(labels):
|
||||
continue
|
||||
try:
|
||||
label = int(float(labels[class_id]))
|
||||
except (TypeError, ValueError):
|
||||
continue
|
||||
if label in (20, 40):
|
||||
found.append((label, class_id, _det_obj_score(obj)))
|
||||
return found
|
||||
|
||||
objects = _detect(conf_th)
|
||||
candidates = _candidates(objects)
|
||||
if logger and objects:
|
||||
logger.info(
|
||||
"[YOLO-TARGET] 原始框=%d, 解析类别=%s"
|
||||
% (
|
||||
len(objects),
|
||||
[(_det_obj_class_id(o), _det_obj_score(o)) for o in objects[:8]],
|
||||
)
|
||||
)
|
||||
if not candidates and bool(
|
||||
getattr(cfg, "TARGET_CLASS_YOLO_RETRY_ON_EMPTY", False)
|
||||
):
|
||||
retry_th = float(getattr(cfg, "TARGET_CLASS_YOLO_RETRY_CONF_TH", conf_th))
|
||||
if 0 < retry_th < conf_th:
|
||||
candidates = _candidates(_detect(retry_th))
|
||||
|
||||
if not candidates:
|
||||
if logger:
|
||||
logger.warning("[YOLO-TARGET] 当前帧未识别到 20/40,按未知处理")
|
||||
return None
|
||||
|
||||
label, class_id, confidence = max(candidates, key=lambda item: item[2])
|
||||
result = {"label": label, "class_id": class_id, "confidence": confidence}
|
||||
if logger:
|
||||
logger.info(
|
||||
f"[YOLO-TARGET] 当前帧分类={label}, class_id={class_id}, "
|
||||
f"conf={confidence:.3f}"
|
||||
)
|
||||
return result
|
||||
|
||||
|
||||
def _det_to_src_xyxy(o, coord_mode: str, src_w: int, src_h: int, net_w: int, net_h: int):
|
||||
"""把单个检测框转为全图坐标系下的 xyxy(半开区间语义与后续 clip 一致)。"""
|
||||
x, y, w, h = float(o.x), float(o.y), float(o.w), float(o.h)
|
||||
|
||||
Binary file not shown.
Binary file not shown.
@@ -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()
|
||||
@@ -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()
|
||||
@@ -29,3 +29,12 @@
|
||||
# 2.15.16 修复wifi连接问题
|
||||
# 2.15.17 修复wifi连接问题
|
||||
# 2.15.18 wifi连接成功重新登录
|
||||
# 2.16.4 优化射箭延迟
|
||||
# 2.17.0 yolo标靶类别识别
|
||||
# 2.17.1 26-08-19 17:39 压力传感修改 增量方式
|
||||
|
||||
# 2.17.2 26-08-24 17:56 靶纸识别模型更替
|
||||
|
||||
# 2.17.3 26-08-25 9:57 原图拍摄开关
|
||||
|
||||
# 2.17.4 26-08-25 14:57 模型修改
|
||||
+1
-1
@@ -4,6 +4,6 @@
|
||||
应用版本号
|
||||
每次 OTA 更新时,只需要更新这个文件中的版本号
|
||||
"""
|
||||
VERSION = '2.16.3'
|
||||
VERSION = '2.17.15'
|
||||
|
||||
|
||||
|
||||
@@ -908,7 +908,12 @@ def _save_worker_loop():
|
||||
item = _save_queue.get()
|
||||
if item is None:
|
||||
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:
|
||||
logger = logger_manager.logger
|
||||
if logger:
|
||||
@@ -936,6 +941,56 @@ def start_save_shot_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,
|
||||
laser_point, distance_m, shot_id=None, photo_dir=None,
|
||||
yolo_roi_xyxy=None, force_save=False):
|
||||
|
||||
Reference in New Issue
Block a user