Files
archery/target_roi_yolo.py
2026-05-11 16:26:05 +08:00

669 lines
23 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 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 ImportError:
return None
_detector_by_path[model_path] = nn.YOLOv5(model=model_path, dual_buff=False)
return _detector_by_path[model_path]
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,)
)
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:
return int(float(v))
except (TypeError, ValueError):
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_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 相同的全图 RGBnumpyH×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