Files
archery/target_roi_yolo.py
T
linyimin 8d35bf3068 feat: 2.18.10 靶规格复检与置信度门槛
- 检出靶框裁剪放大后二次 detect 复检(conf 0.25),以复检结果为准
- 全图结果上报门槛 MIN_REPORT_CONF=0.5,复检结果 >0.25 即采纳
- 0 结果时按 RETRY_CONF_TH 自动重检;class_id=1 解析阶段过滤
- 换用 model_326162,去除调试 print 改走 logger
2026-10-23 15:13:48 +08:00

998 lines
36 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
MaixCAM NPU YOLOv5:先检靶环/整靶区域并裁切 ROI;黑三角 Stage2 在裁切图上推理(与训练一致),
再在各子框上跑传统直角点算法。
- 相机全分辨率(如 640×480)与模型输入(如 320×320)不一致时,需把检测框从
「网络输入坐标系」映回全图,或直接使用 Maix 已映射到源图坐标的模式(见 config)。
依赖:maix.nn.YOLOv5;靶环模型 config.TRIANGLE_YOLO_MODEL_PATH;黑三角模型
config.TRIANGLE_BLACK_YOLO_MODEL_PATH(可多实例缓存,按路径区分)。
224×224、320×320 等「网络输入尺寸」由导出的 .mud 决定,运行时打印为 net_in=,无需在业务 config 里写死。
返回 (x0, y0, x1, y1) 为整幅 img_cv 上的轴对齐矩形,半开区间按三角形裁剪习惯:
实际裁剪为 img[y0:y1, x0:x1]。
"""
from __future__ import annotations
import os
import threading
import numpy as np
def _stage2_roi_crop_save_worker(
slab_rgb,
out_local_boxes,
rx0,
ry0,
rw,
rh,
base_dir,
draw_boxes,
jpeg_quality,
roi_max_images,
logger_ref,
):
"""后台写 Stage2 裁切 JPEG,避免阻塞 NPU 后续流程。"""
try:
import time
import cv2
os.makedirs(base_dir, exist_ok=True)
fn = os.path.join(
base_dir,
f"stage2_roi_{rx0}_{ry0}_{rw}x{rh}_{int(time.time() * 1000)}.jpg",
)
bgr = cv2.cvtColor(slab_rgb, cv2.COLOR_RGB2BGR)
if draw_boxes and out_local_boxes:
for i, (bx0, by0, bx1, by1) in enumerate(out_local_boxes):
x0, y0 = int(bx0), int(by0)
x1, y1 = int(bx1) - 1, int(by1) - 1
x1 = max(x0, min(x1, rw - 1))
y1 = max(y0, min(y1, rh - 1))
cv2.rectangle(bgr, (x0, y0), (x1, y1), (0, 255, 0), 2)
cv2.putText(
bgr,
f"s2_{i}",
(x0, max(0, y0 - 4)),
cv2.FONT_HERSHEY_SIMPLEX,
0.5,
(0, 255, 0),
1,
cv2.LINE_AA,
)
cv2.imwrite(fn, bgr, [int(cv2.IMWRITE_JPEG_QUALITY), int(jpeg_quality)])
try:
from vision import prune_old_images_in_dir
prune_old_images_in_dir(
base_dir, roi_max_images, logger_ref, "[YOLO-BLACK]"
)
except Exception:
pass
if logger_ref:
extra = (
f",已绘 Stage2 框×{len(out_local_boxes)}"
if (draw_boxes and out_local_boxes)
else ""
)
logger_ref.info(f"[YOLO-BLACK] 已保存 Stage1 裁切图(异步): {fn}{extra}")
except Exception as e:
if logger_ref:
logger_ref.warning(f"[YOLO-BLACK] 异步保存裁切图失败: {e}")
_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
_detector_by_path.clear()
def _get_detector(model_path: str):
global _detector_by_path
if not model_path or not os.path.isfile(model_path):
return None
if model_path in _detector_by_path:
return _detector_by_path[model_path]
try:
from maix import nn
except Exception:
return None
# 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):
"""
启动阶段预加载 YOLO detector,避免第一次真实射箭承担模型加载开销。
detect 使用 dual_buff=False,不再需要用首帧 warmup 抵消双缓冲的一帧延迟。
"""
try:
import config as cfg
except Exception as e:
if logger:
logger.warning(f"[YOLO-ROI] 预加载失败:无法读取 config: {e}")
return False
ok = False
if bool(getattr(cfg, "TRIANGLE_YOLO_ROI_ENABLE", False)):
model_path = getattr(cfg, "TRIANGLE_YOLO_MODEL_PATH", "") or ""
det = _get_detector(model_path)
if det is None:
if logger:
logger.warning(f"[YOLO-ROI] 预加载失败:无法加载模型 {model_path}")
else:
ok = True
try:
net_w = int(det.input_width())
net_h = int(det.input_height())
except Exception:
net_w = net_h = -1
if logger:
logger.info(
f"[YOLO-ROI] 靶环模型已预加载: {model_path}, net_in={net_w}×{net_h}"
)
_loc_black = str(
getattr(cfg, "TRIANGLE_BLACK_TRIANGLE_LOCATE_MODE", "yolo")
).lower().strip()
if _loc_black not in ("yolo", "traditional"):
_loc_black = "yolo"
_preload_black = (
bool(getattr(cfg, "TRIANGLE_BLACK_YOLO_ENABLE", False))
and _loc_black == "yolo"
and bool(getattr(cfg, "TRIANGLE_BLACK_YOLO_PRELOAD_ON_BOOT", True))
)
if _preload_black:
bp = getattr(cfg, "TRIANGLE_BLACK_YOLO_MODEL_PATH", "") or ""
d2 = _get_detector(bp)
if d2 is None:
if logger:
logger.warning(f"[YOLO-BLACK] 预加载失败:无法加载模型 {bp}")
else:
ok = True
try:
nw2 = int(d2.input_width())
nh2 = int(d2.input_height())
except Exception:
nw2 = nh2 = -1
if logger:
logger.info(
f"[YOLO-BLACK] 黑三角模型已预加载: {bp}, net_in={nw2}×{nh2}"
)
elif logger and bool(getattr(cfg, "TRIANGLE_BLACK_YOLO_ENABLE", False)):
if _loc_black != "yolo":
logger.info(
"[YOLO-BLACK] TRIANGLE_BLACK_TRIANGLE_LOCATE_MODE=%s:跳过黑三角模型预加载"
% (_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
def _letterbox_net_to_src_xyxy(
x: float, y: float, w: float, h: float,
src_w: int, src_h: int, net_w: int, net_h: int,
):
"""
检测框在网络输入图上(含 letterbox 填充),映回到 src_w×src_h 原图。
x,y,w,h 为网络坐标系下的左上角与宽高。
"""
scale = min(net_w / float(src_w), net_h / float(src_h))
nw = src_w * scale
nh = src_h * scale
pad_x = (net_w - nw) * 0.5
pad_y = (net_h - nh) * 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_obj_class_id(o):
"""Maix / 不同版本可能用 class_id、cls、label 等字段。"""
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:
if callable(v):
v = v()
return int(float(v))
except (TypeError, ValueError, AttributeError):
continue
return None
def _det_obj_from_seq(t):
"""若 detect 返回 list/tuple:[x,y,w,h,score,cls](Maix 常用 xywh),包装成属性对象。"""
if not isinstance(t, (list, tuple)) or len(t) < 6:
return None
class _Box:
__slots__ = ("x", "y", "w", "h", "score", "class_id")
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 _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):
"""调用 YOLO 检测:class_id∈IGNORE_CLASS_IDS(默认 1) 的框在解析时直接过滤;
detections 输出其余全部框(不按置信度过滤);
上报以「检出框裁剪放大后的复检结果」为主,复检不可用时用全图结果;
有效靶规格(20/40)中取置信度最高的一条,且低于 MIN_REPORT_CONF 不上报类别。"""
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))
# class_id → 靶规格标签,下标即 class_id:0→40, 1→0, 2→20
labels = getattr(cfg, "TARGET_CLASS_YOLO_LABELS", (40, 0, 20))
if isinstance(labels, str):
labels = tuple(x.strip() for x in labels.split(",") if x.strip())
labels = tuple(labels)
# 只有映射到这些标签的检测才算有效靶规格(class 1→label 0 直接忽略)
valid_labels = getattr(cfg, "TARGET_CLASS_YOLO_VALID_LABELS", (20, 40))
if isinstance(valid_labels, str):
valid_labels = tuple(
int(float(x.strip())) for x in valid_labels.split(",") if x.strip()
)
valid_labels = frozenset(int(float(v)) for v in valid_labels)
# 解析阶段就丢弃这些 class_id(1=靶心),不进入 detections,也不参与上报选择
ignore_class_ids = getattr(cfg, "TARGET_CLASS_YOLO_IGNORE_CLASS_IDS", (1,))
if isinstance(ignore_class_ids, int):
ignore_class_ids = (ignore_class_ids,)
if isinstance(ignore_class_ids, str):
ignore_class_ids = tuple(
int(float(x.strip())) for x in ignore_class_ids.split(",") if x.strip()
)
ignore_class_ids = frozenset(int(float(v)) for v in ignore_class_ids)
n_ignored = [0]
def _run(threshold):
try:
return 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 None
def _obj_box(o):
try:
return (
round(float(o.x), 1),
round(float(o.y), 1),
round(float(o.w), 1),
round(float(o.h), 1),
)
except (TypeError, ValueError, AttributeError):
return None
def _parse(raw_result):
out = []
n_ignored[0] = 0
for obj in _normalize_objs(raw_result if raw_result is not None else []):
class_id = _det_obj_class_id(obj)
if class_id is None or class_id in ignore_class_ids:
n_ignored[0] += 1
continue
label = None
if 0 <= class_id < len(labels):
try:
label = int(float(labels[class_id]))
except (TypeError, ValueError):
label = None
out.append(
{
"class_id": class_id,
"label": label,
"confidence": round(_det_obj_score(obj), 4),
"box_xywh": _obj_box(obj),
}
)
return out
# 第 1 次检测
raw = _run(conf_th)
detections = _parse(raw)
# 0 结果时重新检测一次(阈值取更小值,仍为 0 则同参数重跑)
if not detections:
retry_th = float(getattr(cfg, "TARGET_CLASS_YOLO_RETRY_CONF_TH", 0.25) or 0)
if not (0 < retry_th < conf_th):
retry_th = conf_th
if logger:
logger.info(f"[YOLO-TARGET] 0 结果,重新检测 conf_th={retry_th}")
raw_retry = _run(retry_th)
detections_retry = _parse(raw_retry)
if detections_retry:
detections = detections_retry
if logger:
logger.info(
f"[YOLO-TARGET] 检测结果={detections}"
f"(已忽略 class_id∈{tuple(sorted(ignore_class_ids))} 共{n_ignored[0]} 条)"
)
if not detections:
if logger:
logger.warning("[YOLO-TARGET] 未检测到目标(已重检 1 次)")
return None
def _best_valid(dets):
valid = [d for d in dets if d["label"] in valid_labels]
if not valid:
return None
return max(valid, key=lambda d: d["confidence"])
def _crop_target(box, margin_frac):
"""按检出框(带 margin)裁出放大 ROI;返回 Maix image,失败返回 None。"""
if not box or len(box) != 4:
return None
x, y, w, h = [float(v) for v in box]
try:
fw, fh = int(maix_frame.width()), int(maix_frame.height())
except Exception:
fw = fh = 0
mx, my = w * margin_frac, h * margin_frac
x0, y0 = int(max(0, x - mx)), int(max(0, y - my))
x1 = int(min(fw, x + w + mx)) if fw else int(x + w + mx)
y1 = int(min(fh, y + h + my)) if fh else int(y + h + my)
if (x1 - x0) < 32 or (y1 - y0) < 32:
if logger:
logger.warning(
f"[YOLO-TARGET] 复检裁剪过小 [{x0},{y0},{x1},{y1}]"
)
return None
try:
cropped = maix_frame.crop(x0, y0, x1 - x0, y1 - y0)
if cropped is not None:
return cropped
except Exception:
pass
try:
from maix import image as maix_image
arr = maix_frame.to_numpy()
slab = np.ascontiguousarray(arr[y0:y1, x0:x1], dtype=np.uint8)
if slab.size == 0:
return None
return maix_image.cv2image(slab, False, True)
except Exception as exc:
if logger:
logger.warning(f"[YOLO-TARGET] 复检裁剪失败: {exc}")
return None
# 上报口径:只在有效靶规格(20/40)里取置信度最高的一条,class_id=1 已在解析阶段过滤
full_best = _best_valid(detections)
# 二次裁剪复检:把全图检出的靶框裁剪放大后再 detect 一次,与全图结果互相印证
# 复检用更低阈值 RECHECK_CONF_TH,置信度大于该值即接受
recheck_th = float(getattr(cfg, "TARGET_CLASS_YOLO_RECHECK_CONF_TH", 0.25))
crop_best = None
recheck_status = "off"
if full_best is not None and bool(
getattr(cfg, "TARGET_CLASS_YOLO_RECHECK_CROP", True)
):
recheck_status = "fail"
margin_frac = float(
getattr(cfg, "TARGET_CLASS_YOLO_RECHECK_MARGIN_FRAC", 0.1)
)
crop_img = _crop_target(full_best["box_xywh"], margin_frac)
if crop_img is not None:
try:
raw_crop = detector.detect(
crop_img, conf_th=recheck_th, iou_th=iou_th
)
except Exception as exc:
raw_crop = None
if logger:
logger.warning(f"[YOLO-TARGET] 复检 detect 异常: {exc}")
if raw_crop is not None:
crop_dets = _parse(raw_crop)
recheck_status = "ok"
crop_best = _best_valid(crop_dets)
if crop_best is not None and crop_best["confidence"] <= recheck_th:
crop_best = None
if logger:
logger.info(
f"[YOLO-TARGET] 复检(conf_th={recheck_th}, "
f"裁剪框={full_best['box_xywh']}) 结果={crop_dets}"
)
# 合并:以复检(裁剪放大)结果为主;复检无结果才用全图
picked = full_best
picked_from_crop = False
note = ""
if recheck_status == "ok":
if crop_best is None:
note = f"复检无 conf>{recheck_th} 的有效框,沿用全图"
else:
picked = crop_best
picked_from_crop = True
if crop_best["label"] == full_best["label"]:
note = f"复检一致({crop_best['label']}),以复检为准"
else:
note = (
f"复检与全图不一致(全图={full_best['label']}@{full_best['confidence']}, "
f"裁剪={crop_best['label']}@{crop_best['confidence']}),以复检为准"
)
# 置信度门槛:低于阈值不上报类别,避免硬猜(复检结果只需 > RECHECK_CONF_TH)
if picked is not None:
if picked_from_crop:
min_report_conf = recheck_th
below = picked["confidence"] <= min_report_conf
else:
min_report_conf = float(
getattr(cfg, "TARGET_CLASS_YOLO_MIN_REPORT_CONF", 0.5)
)
below = picked["confidence"] < min_report_conf
if below:
note = (note + "; " if note else "") + (
f"conf={picked['confidence']} 未达门槛{min_report_conf},置信度不足"
)
picked = None
if picked is None:
label, class_id, confidence = None, None, None
else:
label = picked["label"]
class_id = picked["class_id"]
confidence = picked["confidence"]
if logger:
logger.info(
f"[YOLO-TARGET] 上报={label}, class_id={class_id}, conf={confidence}, "
f"全图候选={[(d['label'], d['confidence']) for d in detections if d['label'] in valid_labels]}, "
f"复检={recheck_status} {note}"
)
if note:
logger.warning(f"[YOLO-TARGET] {note}")
return {
"label": label,
"class_id": class_id,
"confidence": confidence,
"detections": detections,
}
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)
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 _merge_roi_xyxy(xy_list, merge_mode: str):
"""
merge_mode:
union — 所有框的外接矩形(适合「整靶+多角标」同属一类、多框场景)
largest — 取面积最大的单个框(适合只有一个大框代表整靶)
"""
if not xy_list:
return None
if merge_mode in ("union", "merge", "all"):
x0 = min(a[0] for a in xy_list)
y0 = min(a[1] for a in xy_list)
x1 = max(a[2] for a in xy_list)
y1 = max(a[3] for a in xy_list)
return x0, y0, x1, y1
# largest
def _area(t):
return max(0.0, t[2] - t[0]) * max(0.0, t[3] - t[1])
best = max(xy_list, key=_area)
return best[0], best[1], best[2], best[3]
def _roi_aspect_sane(x0, y0, x1, y1, src_w: int, src_h: int) -> bool:
"""过滤 letterbox 重复映射等导致的扁条/细条 ROI。"""
bw = x1 - x0
bh = y1 - y0
if bw < 8 or bh < 8:
return False
area_frac = (bw * bh) / float(max(1, src_w * src_h))
if area_frac < 0.015: # 小于全图约 1.5% 认为不可信
return False
ar = bw / max(bh, 1e-6)
if ar > 5.5 or ar < 1.0 / 5.5:
return False
return True
def _expand_xyxy(x0, y0, x1, y1, src_w, src_h, margin_frac: float):
bw = max(x1 - x0, 1e-6)
bh = max(y1 - y0, 1e-6)
mx = bw * margin_frac
my = bh * margin_frac
x0 -= mx
y0 -= my
x1 += mx
y1 += my
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, y1
def try_get_triangle_roi_from_yolo(maix_frame, src_w: int, src_h: int, logger=None):
"""
用 YOLO 在 maix_frame 上检测靶环类,返回整图上的裁剪框 (x0,y0,x1,y1);失败返回 None。
:param maix_frame: camera.read() 返回的 Maix 图像(与 nn.YOLOv5.detect 一致)
:param src_w, src_h: 与 img_cv / 标定一致的分辨率(通常与 camera 一致)
"""
try:
import config as cfg
except Exception:
return None
if not bool(getattr(cfg, "TRIANGLE_YOLO_ROI_ENABLE", False)):
return None
model_path = getattr(cfg, "TRIANGLE_YOLO_MODEL_PATH", "") or ""
if not os.path.isfile(model_path):
if logger:
logger.warning(f"[YOLO-ROI] 模型文件不存在: {model_path}")
return None
det = _get_detector(model_path)
if det is None:
if logger:
logger.warning("[YOLO-ROI] 无法加载 nn.YOLOv5(非 Maix 环境或导入失败)")
return None
conf_th = float(getattr(cfg, "TRIANGLE_YOLO_CONF_TH", 0.5))
iou_th = float(getattr(cfg, "TRIANGLE_YOLO_IOU_TH", 0.45))
class_ids = getattr(cfg, "TRIANGLE_YOLO_RING_CLASS_IDS", (0,))
if isinstance(class_ids, int):
class_ids = (class_ids,)
margin_frac = float(getattr(cfg, "TRIANGLE_YOLO_ROI_MARGIN_FRAC", 0.12))
coord_mode = str(getattr(cfg, "TRIANGLE_YOLO_COORD_MODE", "native")).lower()
merge_mode = str(getattr(cfg, "TRIANGLE_YOLO_ROI_MERGE_MODE", "union")).lower()
reject_bad = bool(getattr(cfg, "TRIANGLE_YOLO_REJECT_BAD_ROI", True))
try:
raw = det.detect(maix_frame, conf_th=conf_th, iou_th=iou_th)
except Exception as e:
if logger:
logger.warning(f"[YOLO-ROI] detect 异常: {e}")
return None
objs = _normalize_objs(raw if raw is not None else [])
candidates = []
for o in objs:
cid = _det_obj_class_id(o)
if cid is not None and cid in class_ids:
candidates.append(o)
if not candidates and bool(getattr(cfg, "TRIANGLE_YOLO_RETRY_ON_EMPTY", False)):
retry_conf = float(getattr(cfg, "TRIANGLE_YOLO_RETRY_CONF_TH", conf_th))
if retry_conf > 0 and retry_conf < conf_th:
try:
raw_retry = det.detect(maix_frame, conf_th=retry_conf, iou_th=iou_th)
objs_retry = _normalize_objs(raw_retry if raw_retry is not None else [])
candidates_retry = []
for o in objs_retry:
cid = _det_obj_class_id(o)
if cid is not None and cid in class_ids:
candidates_retry.append(o)
if candidates_retry:
if logger:
logger.info(
f"[YOLO-ROI] conf={conf_th} 下 0 候选,"
f"用 retry_conf={retry_conf} 重试得到 {len(candidates_retry)} 个候选"
)
objs = objs_retry
candidates = candidates_retry
conf_th = retry_conf
elif logger:
logger.info(
f"[YOLO-ROI] conf={conf_th} 下 0 候选;"
f"retry_conf={retry_conf} 仍为 0 候选"
)
except Exception as e:
if logger:
logger.warning(f"[YOLO-ROI] 低阈值重试异常: {e}")
if not candidates:
if logger:
n = len(objs)
if n == 0:
logger.info(
f"[YOLO-ROI] detect 返回 0 个框(conf≥{conf_th})。"
f"可尝试 config 里降低 TRIANGLE_YOLO_CONF_TH(如 0.25~0.35),"
f"或确认射箭帧与训练图光照/构图接近。"
)
else:
seen = []
for o in objs[:8]:
cid = _det_obj_class_id(o)
sc = getattr(o, "score", None)
try:
sc_f = float(sc) if sc is not None else None
except Exception:
sc_f = None
seen.append(f"cls={cid},score={sc_f}")
logger.info(
f"[YOLO-ROI] 有 {n} 个框但类别不在 {class_ids} 内;"
f"前几条: {seen}。请核对 TRIANGLE_YOLO_RING_CLASS_IDS,"
f"或查看 Maix 文档中检测结果的类别字段名。"
)
return None
net_w = int(det.input_width())
net_h = int(det.input_height())
min_side = float(getattr(cfg, "TRIANGLE_YOLO_MIN_BOX_SIDE_PX", 8.0))
xy_list = []
for o in candidates:
x0n, y0n, x1n, y1n = _det_to_src_xyxy(o, coord_mode, src_w, src_h, net_w, net_h)
bw, bh = x1n - x0n, y1n - y0n
if bw >= min_side and bh >= min_side:
xy_list.append((x0n, y0n, x1n, y1n))
if not xy_list:
if logger:
logger.info(
f"[YOLO-ROI] {len(candidates)} 个候选经 min_side={min_side} 过滤后为空,放弃 ROI"
)
return None
merged = _merge_roi_xyxy(xy_list, merge_mode)
if merged is None:
return None
x0, y0, x1, y1 = merged
# clip 到画布(合并前框可能略越界)
x0 = max(0, min(x0, src_w - 1))
y0 = max(0, min(y0, src_h - 1))
x1 = max(x0 + 1, min(x1, src_w))
y1 = max(y0 + 1, min(y1, src_h))
x0, y0, x1, y1 = _expand_xyxy(x0, y0, x1, y1, src_w, src_h, margin_frac)
if reject_bad and not _roi_aspect_sane(x0, y0, x1, y1, src_w, src_h):
if logger:
logger.warning(
f"[YOLO-ROI] 裁剪框异常(过小或过扁)mode={coord_mode} merge={merge_mode} "
f"→ [{x0},{y0},{x1},{y1}],放弃 ROI、三角形改用整图。"
f"若持续出现可尝试 coord_mode=letterbox/native 切换。"
)
return None
if logger:
nbox = len(candidates)
logger.info(
f"[YOLO-ROI] boxes={nbox} merge={merge_mode} coord={coord_mode} "
f"net_in={net_w}×{net_h}(来自模型) → crop=[{x0},{y0},{x1},{y1}] "
f"({x1-x0}×{y1-y0}px)"
)
return (x0, y0, x1, y1)
def _expand_xyxy_local(x0, y0, x1, y1, w_lim, h_lim, margin_frac: float):
"""在宽 w_lim、高 h_lim 的局部坐标系内扩展框。"""
bw = max(x1 - x0, 1e-6)
bh = max(y1 - y0, 1e-6)
mx = bw * margin_frac
my = bh * margin_frac
x0 -= mx
y0 -= my
x1 += mx
y1 += my
x0 = max(0, min(int(round(x0)), w_lim - 1))
y0 = max(0, min(int(round(y0)), h_lim - 1))
x1 = max(x0 + 1, min(int(round(x1)), w_lim))
y1 = max(y0 + 1, min(int(round(y1)), h_lim))
return x0, y0, x1, y1
def try_black_triangle_boxes_work(img_rgb, ring_roi_xyxy, logger=None):
"""
Stage2:在 **Stage1 靶环 ROI 裁切图** 上跑黑三角 YOLO(与训练时 stage2 构图一致),
检测框坐标已落在 **靶环裁切图**(与 try_triangle_scoring 中 img_work)同一坐标系,
返回 (x0,y0,x1,y1) 整数元组列表。
img_rgb: 与 try_triangle_scoring 相同的全图 RGB(numpy,H×W×3)。
ring_roi_xyxy: 全图上的 (rx0, ry0, rx1, ry1),与 try_get_triangle_roi_from_yolo 一致。
"""
if ring_roi_xyxy is None:
return []
if img_rgb is None or getattr(img_rgb, "size", 0) == 0:
return []
try:
import config as cfg
except Exception:
return []
if not bool(getattr(cfg, "TRIANGLE_BLACK_YOLO_ENABLE", False)):
return []
model_path = getattr(cfg, "TRIANGLE_BLACK_YOLO_MODEL_PATH", "") or ""
if not os.path.isfile(model_path):
if logger:
logger.warning(f"[YOLO-BLACK] 模型文件不存在: {model_path}")
return []
det = _get_detector(model_path)
if det is None:
if logger:
logger.warning("[YOLO-BLACK] 无法加载 nn.YOLOv5")
return []
conf_th = float(getattr(cfg, "TRIANGLE_BLACK_YOLO_CONF_TH", 0.5))
iou_th = float(getattr(cfg, "TRIANGLE_BLACK_YOLO_IOU_TH", 0.45))
class_ids = getattr(cfg, "TRIANGLE_BLACK_YOLO_CLASS_IDS", (0,))
if isinstance(class_ids, int):
class_ids = (class_ids,)
coord_mode = str(getattr(cfg, "TRIANGLE_BLACK_YOLO_COORD_MODE", "native")).lower()
margin_frac = float(getattr(cfg, "TRIANGLE_BLACK_YOLO_BOX_MARGIN_FRAC", 0.08))
min_side = float(getattr(cfg, "TRIANGLE_BLACK_YOLO_MIN_BOX_SIDE_PX", 6.0))
crop_min = int(getattr(cfg, "TRIANGLE_CROP_ROI_MIN_SIDE_PX", 64))
h_full, w_full = int(img_rgb.shape[0]), int(img_rgb.shape[1])
rx0, ry0, rx1, ry1 = [int(round(float(v))) for v in ring_roi_xyxy]
rx0 = max(0, min(rx0, w_full - 1))
ry0 = max(0, min(ry0, h_full - 1))
rx1 = max(rx0 + 1, min(rx1, w_full))
ry1 = max(ry0 + 1, min(ry1, h_full))
rw, rh = rx1 - rx0, ry1 - ry0
if rw < crop_min or rh < crop_min:
if logger:
logger.warning(
f"[YOLO-BLACK] Stage1 ROI 过小 {rw}×{rh} < {crop_min},跳过黑三角检测"
)
return []
# 必须与相机帧缓冲区脱钩:切片常为非连续视图,直接喂 cv2image/NPU 易 SIGSEGV
slab = np.ascontiguousarray(
img_rgb[ry0:ry1, rx0:rx1], dtype=np.uint8
).copy()
if slab.size == 0:
return []
_save_roi = bool(getattr(cfg, "TRIANGLE_BLACK_YOLO_SAVE_ROI_CROP", False))
try:
from maix import image as maix_image
# copy=True:零拷贝时 detect 内 OpenCV 可能对底层 Mat release 触发 !fixedSize() 断言。
roi_maix = maix_image.cv2image(slab, False, True)
except Exception as e:
if logger:
logger.warning(f"[YOLO-BLACK] 裁切图转 Maix image 失败: {e}")
return []
try:
raw = det.detect(roi_maix, conf_th=conf_th, iou_th=iou_th)
except Exception as e:
if logger:
logger.warning(f"[YOLO-BLACK] detect 异常: {e}")
return []
objs = _normalize_objs(raw if raw is not None else [])
net_w = int(det.input_width())
net_h = int(det.input_height())
n_raw = len(objs)
n_cls_ok = 0
n_too_small = 0
out_local = []
for o in objs:
cid = _det_obj_class_id(o)
if cid is None or cid not in class_ids:
continue
n_cls_ok += 1
x0f, y0f, x1f, y1f = _det_to_src_xyxy(o, coord_mode, rw, rh, net_w, net_h)
lx0 = max(0, min(float(x0f), rw - 1))
ly0 = max(0, min(float(y0f), rh - 1))
lx1 = max(lx0 + 1, min(float(x1f), rw))
ly1 = max(ly0 + 1, min(float(y1f), rh))
lx0, ly0, lx1, ly1 = int(round(lx0)), int(round(ly0)), int(round(lx1)), int(round(ly1))
if (lx1 - lx0) < min_side or (ly1 - ly0) < min_side:
n_too_small += 1
continue
lx0, ly0, lx1, ly1 = _expand_xyxy_local(
lx0, ly0, lx1, ly1, rw, rh, margin_frac
)
out_local.append((lx0, ly0, lx1, ly1))
out_local.sort(key=lambda t: ((t[1] + t[3]) * 0.5, (t[0] + t[2]) * 0.5))
if logger and bool(
getattr(cfg, "TRIANGLE_BLACK_YOLO_LOG_EACH_SHOT", True)
):
msg = (
f"[YOLO-BLACK] Stage1裁切{rw}×{rh}上推理: raw={n_raw} 类∈{class_ids}→{n_cls_ok} "
f"过小丢弃→{n_too_small} 最终子框={len(out_local)} "
f"(conf={conf_th}, coord={coord_mode}, net={net_w}×{net_h}, "
f"ring全图=[{rx0},{ry0},{rx1},{ry1}])"
)
logger.info(msg)
if n_raw > 0 and n_cls_ok == 0:
seen = []
for o in objs[:8]:
cid = _det_obj_class_id(o)
sc = getattr(o, "score", None)
try:
sc_f = float(sc) if sc is not None else None
except Exception:
sc_f = None
seen.append(f"cls={cid},score={sc_f}")
logger.info(
f"[YOLO-BLACK] 有框但类别不在 {class_ids} 内;前几条: {seen}。"
f"请核对 TRIANGLE_BLACK_YOLO_CLASS_IDS。"
)
elif n_cls_ok > 0 and len(out_local) == 0:
logger.info(
f"[YOLO-BLACK] {n_cls_ok} 个目标类框但边长均 < min_side={min_side},已全部丢弃。"
)
if _save_roi:
try:
base = (getattr(cfg, "TRIANGLE_BLACK_YOLO_ROI_CROP_DIR", "") or "").strip()
if not base:
base = os.path.join(
getattr(cfg, "PHOTO_DIR", "/tmp") or "/tmp", "stage2_roi"
)
_draw = bool(
getattr(cfg, "TRIANGLE_BLACK_YOLO_SAVE_ROI_DRAW_BOXES", True)
)
_roi_max_raw = getattr(
cfg, "TRIANGLE_BLACK_YOLO_STAGE2_ROI_MAX_IMAGES", None
)
try:
_roi_max = (
int(_roi_max_raw)
if _roi_max_raw is not None
else int(getattr(cfg, "MAX_IMAGES", 1000))
)
except (TypeError, ValueError):
_roi_max = int(getattr(cfg, "MAX_IMAGES", 1000))
slab_copy = np.ascontiguousarray(slab, dtype=np.uint8).copy()
boxes_copy = [tuple(t) for t in out_local]
threading.Thread(
target=_stage2_roi_crop_save_worker,
args=(
slab_copy,
boxes_copy,
rx0,
ry0,
rw,
rh,
base,
_draw,
92,
_roi_max,
logger,
),
daemon=True,
).start()
except Exception as e:
if logger:
logger.warning(f"[YOLO-BLACK] 提交异步保存裁切图失败: {e}")
return out_local