This commit is contained in:
yrx
2026-05-29 16:24:04 +08:00
parent 575e690868
commit 64722f4d73
17 changed files with 1647 additions and 77 deletions

View File

@@ -0,0 +1,403 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
实时摄像头预览叠加与射箭存图相同的算法标注YOLO ROI、三角/圆心、激光十字等),默认不写盘。
在 MaixCAM 上从项目根目录运行:
python3 test/test_algo_preview_live.py
python3 test/test_algo_preview_live.py --interval 1.5
python3 test/test_algo_preview_live.py --every-frame
说明:
- 完整算法走 shoot_manager.analyze_shot与 process_shot 一致,含 YOLO + 三角/圆心)。
- 画面标注对齐 process_shot 存图前绘制 + vision._draw_yolo_roi_on_rgb_numpy / 圆心存图线。
- 预览模式会关闭 Stage2 裁切 JPEG 落盘,避免写满 /root/phot。
"""
from __future__ import annotations
import argparse
import math
import os
import sys
import threading
import time
_ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
if _ROOT not in sys.path:
sys.path.insert(0, _ROOT)
import cv2
import numpy as np
from maix import image, time as maix_time
import config
from camera_manager import camera_manager
from laser_manager import laser_manager
from shoot_manager import analyze_shot, preload_triangle_calib
from target_roi_yolo import preload_yolo_detector
from vision import _draw_yolo_roi_on_rgb_numpy
def _copy_maix_frame(frame):
"""相机下一帧可能复用缓冲区,异步分析前先复制。"""
img_cv = image.image2cv(frame, False, False)
return image.cv2image(np.ascontiguousarray(img_cv), False, False)
def _patch_preview_config():
"""预览不写调试 JPEG避免刷屏占存储。"""
config.TRIANGLE_BLACK_YOLO_SAVE_ROI_CROP = False
config.TRIANGLE_SAVE_DEBUG_IMAGE = False
def _annotate_like_saved_shot(analysis: dict):
"""
将 analyze_shot 结果绘制成与 process_shot -> enqueue_save_shot 存盘前一致的 Maix 图。
"""
result_img = analysis.get("result_img")
if result_img is None:
return None
center = analysis.get("center")
radius = analysis.get("radius")
method = analysis.get("method")
ellipse_params = analysis.get("ellipse_params")
laser_point = analysis.get("laser_point")
dx = analysis.get("dx")
dy = analysis.get("dy")
distance_m = analysis.get("distance_m")
offset_method = analysis.get("offset_method", "")
distance_method = analysis.get("distance_method", "")
tri_markers = analysis.get("tri_markers") or []
tri_markers_completed = analysis.get("tri_markers_completed") or []
tri_homography = analysis.get("tri_homography")
yolo_roi_xyxy = analysis.get("yolo_roi_xyxy")
if laser_point is None:
return result_img
x, y = laser_point
draw_yolo_roi = (
yolo_roi_xyxy is not None
and getattr(config, "TRIANGLE_YOLO_DRAW_ROI_ON_SHOT", True)
)
if tri_markers:
img_cv = image.image2cv(result_img, False, False).copy()
if draw_yolo_roi:
_draw_yolo_roi_on_rgb_numpy(img_cv, yolo_roi_xyxy)
for m in tri_markers:
corners = np.array(m["corners"], dtype=np.int32)
cv2.polylines(img_cv, [corners], True, (0, 255, 0), 2)
cx, cy = int(m["center"][0]), int(m["center"][1])
cv2.circle(img_cv, (cx, cy), 4, (0, 0, 255), -1)
cv2.putText(
img_cv,
f"T{m['id']}",
(cx - 18, cy - 12),
cv2.FONT_HERSHEY_SIMPLEX,
0.55,
(0, 255, 0),
1,
)
for m in tri_markers_completed:
if not m.get("is_virtual"):
continue
cx, cy = int(m["center"][0]), int(m["center"][1])
cv2.circle(img_cv, (cx, cy), 6, (255, 0, 255), 2)
cv2.putText(
img_cv,
f"VT{m['id']}",
(cx - 22, cy - 12),
cv2.FONT_HERSHEY_SIMPLEX,
0.55,
(255, 0, 255),
1,
)
if tri_homography is not None:
try:
H_inv = np.linalg.inv(tri_homography)
c_img = cv2.perspectiveTransform(
np.array([[[0.0, 0.0]]], dtype=np.float32), H_inv
)[0][0]
ocx, ocy = int(c_img[0]), int(c_img[1])
cv2.circle(img_cv, (ocx, ocy), 5, (0, 0, 255), -1)
cv2.circle(img_cv, (ocx, ocy), 9, (0, 0, 255), 1)
except Exception:
pass
lines = []
if dx is not None and dy is not None:
r_cm = math.hypot(float(dx), float(dy))
lines.append(f"offset=({float(dx):.2f},{float(dy):.2f})cm |r|={r_cm:.2f}cm")
if distance_m is not None:
lines.append(f"cam_dist={float(distance_m):.2f}m ({distance_method})")
if method:
lines.append(f"method={method} ({offset_method})")
y0 = 22
for i, t in enumerate(lines):
cv2.putText(
img_cv,
t,
(10, y0 + i * 18),
cv2.FONT_HERSHEY_SIMPLEX,
0.5,
(0, 255, 0),
1,
)
out = image.cv2image(img_cv, False, False)
else:
img_cv = image.image2cv(result_img, False, False).copy()
if draw_yolo_roi:
_draw_yolo_roi_on_rgb_numpy(img_cv, yolo_roi_xyxy)
if center and radius:
cx, cy = center
if ellipse_params:
(ell_center, (width, height), angle) = ellipse_params
cx_ell, cy_ell = int(ell_center[0]), int(ell_center[1])
cv2.ellipse(
img_cv,
(cx_ell, cy_ell),
(int(width / 2), int(height / 2)),
angle,
0,
360,
(0, 255, 0),
2,
)
cv2.circle(img_cv, (cx_ell, cy_ell), 3, (255, 0, 0), -1)
minor_length = min(width, height) / 2
minor_angle = angle + 90 if width >= height else angle
minor_angle_rad = math.radians(minor_angle)
dx_minor = minor_length * math.cos(minor_angle_rad)
dy_minor = minor_length * math.sin(minor_angle_rad)
pt1 = (int(cx_ell - dx_minor), int(cy_ell - dy_minor))
pt2 = (int(cx_ell + dx_minor), int(cy_ell + dy_minor))
cv2.line(img_cv, pt1, pt2, (0, 0, 255), 2)
else:
cv2.circle(img_cv, (int(cx), int(cy)), int(radius), (0, 0, 255), 2)
cv2.circle(img_cv, (int(cx), int(cy)), 2, (0, 0, 255), -1)
cv2.line(img_cv, (int(x), int(y)), (int(cx), int(cy)), (255, 255, 0), 1)
lines = []
if dx is not None and dy is not None:
lines.append(f"offset=({float(dx):.2f},{float(dy):.2f})cm")
if distance_m is not None:
lines.append(f"dist={float(distance_m):.2f}m ({distance_method})")
if method:
lines.append(f"method={method}")
for i, t in enumerate(lines):
cv2.putText(
img_cv,
t,
(10, 22 + i * 18),
cv2.FONT_HERSHEY_SIMPLEX,
0.5,
(0, 255, 0),
1,
)
out = image.cv2image(img_cv, False, False)
lc = image.Color(config.LASER_COLOR[0], config.LASER_COLOR[1], config.LASER_COLOR[2])
out.draw_line(
int(x - config.LASER_LENGTH),
int(y),
int(x + config.LASER_LENGTH),
int(y),
lc,
config.LASER_THICKNESS,
)
out.draw_line(
int(x),
int(y - config.LASER_LENGTH),
int(x),
int(y + config.LASER_LENGTH),
lc,
config.LASER_THICKNESS,
)
out.draw_circle(int(x), int(y), 1, lc, config.LASER_THICKNESS)
return out
class _AlgoWorker:
def __init__(self):
self._lock = threading.Lock()
self._busy = False
self._latest_preview = None
self._latest_meta = ""
self._last_ms = 0.0
@property
def busy(self):
with self._lock:
return self._busy
@property
def last_ms(self):
with self._lock:
return self._last_ms
def get_preview(self):
with self._lock:
return self._latest_preview, self._latest_meta
def run_async(self, frame):
with self._lock:
if self._busy:
return False
self._busy = True
def _job():
t0 = time.perf_counter()
meta = ""
preview = None
try:
analysis = analyze_shot(frame)
if not analysis.get("success"):
reason = analysis.get("reason", "unknown")
meta = f"fail:{reason}"
else:
preview = _annotate_like_saved_shot(analysis)
dx, dy = analysis.get("dx"), analysis.get("dy")
method = analysis.get("method") or "?"
if dx is not None and dy is not None:
meta = f"ok {method} ({dx:.2f},{dy:.2f})cm"
else:
meta = f"ok {method} no_offset"
except Exception as e:
meta = f"err:{e}"
elapsed = (time.perf_counter() - t0) * 1000.0
with self._lock:
self._latest_preview = preview
self._latest_meta = f"{meta} {elapsed:.0f}ms"
self._last_ms = elapsed
self._busy = False
threading.Thread(target=_job, daemon=True).start()
return True
def _draw_status(frame, lines, color=None):
if color is None:
color = image.COLOR_YELLOW
y = 4
for line in lines:
frame.draw_string(4, y, line, color=color)
y += 16
def _save_preview_jpeg(maix_img, out_dir):
os.makedirs(out_dir, exist_ok=True)
fn = os.path.join(out_dir, f"preview_{int(time.time() * 1000)}.jpg")
maix_img.save(fn)
return fn
def main():
parser = argparse.ArgumentParser(description="实时预览射箭算法存图效果")
parser.add_argument(
"--interval",
type=float,
default=2.0,
help="两次完整 analyze_shot 的最小间隔(秒);--every-frame 时忽略",
)
parser.add_argument(
"--every-frame",
action="store_true",
help="每帧都触发算法(很慢,仅调试用)",
)
parser.add_argument(
"--width",
type=int,
default=getattr(config, "CAMERA_WIDTH", 640),
)
parser.add_argument(
"--height",
type=int,
default=getattr(config, "CAMERA_HEIGHT", 480),
)
parser.add_argument(
"--save-dir",
default=config.PHOTO_DIR,
help="按板子按键无;用 --save-every N 每 N 次成功分析存一张",
)
parser.add_argument(
"--save-every",
type=int,
default=0,
help="每成功分析 N 次自动存一张到 --save-dir0=不自动存)",
)
args = parser.parse_args()
_patch_preview_config()
print("[INFO] 预览模式:已关闭 TRIANGLE_BLACK_YOLO_SAVE_ROI_CROP / TRIANGLE_SAVE_DEBUG_IMAGE")
laser_manager.load_laser_point()
preload_triangle_calib()
if getattr(config, "TRIANGLE_YOLO_PRELOAD_ON_BOOT", False) or getattr(
config, "TRIANGLE_BLACK_YOLO_PRELOAD_ON_BOOT", False
):
print("[INFO] 预加载 YOLO …")
preload_yolo_detector()
camera_manager.init_camera(args.width, args.height)
camera_manager.init_display()
worker = _AlgoWorker()
interval_s = 0.0 if args.every_frame else max(0.3, float(args.interval))
last_trigger = 0.0
ok_count = 0
frame_idx = 0
print(
f"[INFO] 摄像头 {args.width}x{args.height} "
f"interval={'每帧' if args.every_frame else f'{interval_s}s'}"
)
print("[INFO] 退出Ctrl+C")
try:
while True:
frame = camera_manager.read_frame()
frame_idx += 1
now = time.perf_counter()
due = args.every_frame or (now - last_trigger >= interval_s)
if due and not worker.busy:
last_trigger = now
worker.run_async(_copy_maix_frame(frame))
preview, meta = worker.get_preview()
if preview is not None:
show_img = preview
status = [f"#{frame_idx}", meta]
if args.save_every > 0 and meta.startswith("ok"):
ok_count += 1
if ok_count % args.save_every == 0:
try:
fn = _save_preview_jpeg(preview, args.save_dir)
status.append(f"saved:{fn}")
except Exception as e:
status.append(f"save_err:{e}")
else:
show_img = frame
if worker.busy:
status = [f"#{frame_idx}", "analyzing…"]
else:
status = [f"#{frame_idx}", "waiting…"]
_draw_status(show_img, status)
camera_manager.show(show_img)
maix_time.sleep_ms(1)
except KeyboardInterrupt:
print("[INFO] 已退出")
if __name__ == "__main__":
main()

18
test/test_yolo26.py Normal file
View File

@@ -0,0 +1,18 @@
from maix import camera, display, image, nn, app
# 1. 初始化模型 (请确保模型文件 .mud 路径正确)
detector = nn.YOLOv5(model="/root/model_279350.mud", dual_buff=True)
# 2. 初始化摄像头,分辨率与模型输入匹配
cam = camera.Camera(detector.input_width(), detector.input_height(), detector.input_format())
disp = display.Display()
# 3. 主循环:实时检测与显示
while not app.need_exit():
img = cam.read() # 从摄像头读取一帧
objs = detector.detect(img, conf_th=0.5, iou_th=0.45) # 执行YOLO11推理
for obj in objs: # 绘制所有检测到的目标
img.draw_rect(obj.x, obj.y, obj.w, obj.h, color=image.COLOR_RED)
msg = f'{detector.labels[obj.class_id]}: {obj.score:.2f}'
img.draw_string(obj.x, obj.y, msg, color=image.COLOR_RED)
disp.show(img) # 更新屏幕显示

View File

@@ -0,0 +1,209 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
摄像头实时 YOLOv5 简易测试脚本。
特点:
- 完全独立脚本,直接 python test/test_yolo_camera_simple.py 运行,不需要传参。
- 不 import config不依赖项目模块。
- 直接调用 maix.nn.YOLOv5(model=..., dual_buff=False)。
- camera.read() 得到的 Maix image 直接送 det.detect()。
- 在画面上画检测框、类别、置信度,并显示到屏幕。
运行环境MaixCAM / MaixPy。
"""
import os
CAMERA_WIDTH = 640
CAMERA_HEIGHT = 480
# 默认与主项目 config.TRIANGLE_YOLO_MODEL_PATH 一致(勿用 /root/yolo26_int8.mud那是占位路径
_MODEL_DEFAULT = "/maixapp/apps/t11/model_270139.mud"
try:
import config as _cfg
MODEL_PATH = getattr(_cfg, "TRIANGLE_YOLO_MODEL_PATH", _MODEL_DEFAULT) or _MODEL_DEFAULT
except Exception:
MODEL_PATH = _MODEL_DEFAULT
CONF_TH = 0.7
IOU_TH = 0.45
# native: Maix detect 返回框已映射到 camera.read() 图像坐标letterbox: 需要从网络输入坐标反算
COORD_MODE = "native"
# 只用于 DRAW_ONLY_CLASS_IDS=True 时过滤显示;默认画所有框
CLASS_IDS = (0,)
DRAW_ONLY_CLASS_IDS = False # True=只画 CLASS_IDS 里的类别False=画所有 YOLO 返回框
def _det_obj_class_id(o):
for key in ("class_id", "cls", "label", "category", "cat_id", "id"):
if hasattr(o, key):
v = getattr(o, key)
if v is None:
continue
try:
return int(float(v))
except (TypeError, ValueError):
continue
return None
def _det_obj_from_seq(t):
if not isinstance(t, (list, tuple)) or len(t) < 6:
return None
class Box:
pass
b = Box()
b.x = float(t[0])
b.y = float(t[1])
b.w = float(t[2])
b.h = float(t[3])
b.score = float(t[4])
b.class_id = int(float(t[5]))
return b
def _normalize_objs(objs):
out = []
for o in objs or []:
if isinstance(o, (list, tuple)):
m = _det_obj_from_seq(o)
if m is not None:
out.append(m)
else:
out.append(o)
return out
def _letterbox_net_to_src_xyxy(x, y, w, h, src_w, src_h, net_w, net_h):
scale = min(net_w / float(src_w), net_h / float(src_h))
new_w = src_w * scale
new_h = src_h * scale
pad_x = (net_w - new_w) * 0.5
pad_y = (net_h - new_h) * 0.5
x0 = (x - pad_x) / scale
y0 = (y - pad_y) / scale
x1 = (x + w - pad_x) / scale
y1 = (y + h - pad_y) / scale
return x0, y0, x1, y1
def _det_to_src_xyxy(o, coord_mode, src_w, src_h, net_w, net_h):
x = float(getattr(o, "x", 0.0))
y = float(getattr(o, "y", 0.0))
w = float(getattr(o, "w", 0.0))
h = float(getattr(o, "h", 0.0))
if coord_mode in ("native", "source", "camera", "full"):
return x, y, x + w, y + h
return _letterbox_net_to_src_xyxy(x, y, w, h, src_w, src_h, net_w, net_h)
def _clip_xywh(x0, y0, x1, y1, src_w, src_h):
x0 = max(0, min(int(round(x0)), src_w - 1))
y0 = max(0, min(int(round(y0)), src_h - 1))
x1 = max(x0 + 1, min(int(round(x1)), src_w))
y1 = max(y0 + 1, min(int(round(y1)), src_h))
return x0, y0, x1 - x0, y1 - y0
def _label(det, cid):
labels = getattr(det, "labels", None)
if labels is None:
return str(cid)
try:
return str(labels[int(cid)])
except Exception:
return str(cid)
def main():
from maix import camera, display, nn, time, image
if not MODEL_PATH or not os.path.isfile(MODEL_PATH):
print("[ERR] 模型文件不存在:", MODEL_PATH)
return
print("[INFO] 初始化 YOLO 模型:", MODEL_PATH)
det = nn.YOLOv26(model=MODEL_PATH, dual_buff=False)
net_w = int(det.input_width())
net_h = int(det.input_height())
print(
"[INFO] net_in=%dx%d conf=%.2f iou=%.2f coord=%s class_ids=%s"
% (net_w, net_h, CONF_TH, IOU_TH, COORD_MODE, str(CLASS_IDS))
)
print("[INFO] 初始化摄像头: %dx%d" % (CAMERA_WIDTH, CAMERA_HEIGHT))
cam = camera.Camera(CAMERA_WIDTH, CAMERA_HEIGHT)
disp = display.Display()
color_cycle = []
for name in ("RED", "GREEN", "BLUE", "ORANGE", "YELLOW", "CYAN", "MAGENTA"):
c = getattr(image, "COLOR_" + name, None)
if c is not None:
color_cycle.append(c)
if not color_cycle:
color_cycle = [getattr(image, "COLOR_RED", 0)]
frame_idx = 0
last_log_ms = time.ticks_ms()
fps_count = 0
while True:
frame = cam.read()
src_w = frame.width()
src_h = frame.height()
t0 = time.ticks_ms()
raw = det.detect(frame, conf_th=CONF_TH, iou_th=IOU_TH)
detect_ms = time.ticks_ms() - t0
objs = _normalize_objs(raw if raw is not None else [])
draw_count = 0
for i, o in enumerate(objs):
cid = _det_obj_class_id(o)
if cid is None:
cid = -1
if DRAW_ONLY_CLASS_IDS and cid not in CLASS_IDS:
continue
try:
score = float(getattr(o, "score", 0.0))
except Exception:
score = 0.0
x0, y0, x1, y1 = _det_to_src_xyxy(o, COORD_MODE, src_w, src_h, net_w, net_h)
ix, iy, iw, ih = _clip_xywh(x0, y0, x1, y1, src_w, src_h)
col = color_cycle[cid % len(color_cycle)] if cid >= 0 else color_cycle[0]
frame.draw_rect(ix, iy, iw, ih, color=col)
frame.draw_string(ix, max(0, iy - 16), "%s %.2f" % (_label(det, cid), score), color=col)
draw_count += 1
frame.draw_string(4, 4, "YOLO boxes:%d draw:%d %dms" % (len(objs), draw_count, detect_ms), color=color_cycle[0])
disp.show(frame)
frame_idx += 1
fps_count += 1
now = time.ticks_ms()
if now - last_log_ms >= 1000:
print(
"[INFO] frame=%d fps=%d raw_boxes=%d draw_boxes=%d detect_ms=%d"
% (frame_idx, fps_count, len(objs), draw_count, detect_ms)
)
fps_count = 0
last_log_ms = now
if __name__ == "__main__":
try:
main()
except KeyboardInterrupt:
print("[INFO] exit")
except Exception as e:
print("[ERR]", e)
try:
import traceback
traceback.print_exc()
except Exception:
pass

View File

@@ -1,10 +1,9 @@
from maix import image, nn, display
# 1. 加载模型
detector = nn.YOLOv8(model="/root/models/yolov8n.mud", dual_buff=True)
detector = nn.YOLOv8(model="/root/279350.mud", dual_buff=False)
# 2. 加载指定图片(根据模型输入尺寸自动缩放宽高)
img = image.load("/root/test.jpg")
img = image.load("/root/tes.jpg")
if img is None:
raise FileNotFoundError("图片加载失败,请检查路径")