新增双目yolo实现python代码

This commit is contained in:
keita
2026-09-14 15:05:03 +08:00
parent 538ae4c6c7
commit 3a8e2c7f4b
65 changed files with 2467 additions and 10 deletions
+109
View File
@@ -0,0 +1,109 @@
# stereo_yolo(Python 版)
双目 YOLO 检测前端:在左右目图像上**独立运行** YOLO 检测器,输出符合
`stereo_ranger` 输入契约的检测框(每目一个 `DetectionFrame`、共用 frame_id)。
不做测距、左右目关联与立体校正。
规格:`docs/双目YOLO目标识别需求说明.md`(接口契约以其为准;几何语义权威
`docs/双目测距技术路线.md`)。
⚠ 全部参数为占位值、demo 素材为 AI 合成图:标定与地面真值验证完成前,
仅限台架/回放验证,不得接入实飞控制回路(规格 §7 红线同源)。
## 接口清单
| 名称 | 说明 |
| --- | --- |
| `StereoYoloFrontend(config, detector=None)` | 主入口;`detector` 可注入(接口 `detect(img) -> list[Detection]`,单测用假检测器) |
| `.detect(left_img, right_img, frame_id) -> (DetectionFrame, DetectionFrame)` | 单帧检测;BGR ndarray(cv2 约定),输出框位于输入图原生坐标系 |
| `.close()` | 释放并发线程池 |
| `.config` | 只读配置访问 |
| `StereoYoloConfig` / `load_from_json(path)` | 7 项配置;缺项/未知字段/类型错误/非法取值一律 `ValueError`(fail-fast) |
| `UltralyticsDetector` | ultralytics YOLO `.pt` 后端(双实例池;`ultralytics` 延迟导入) |
| `split_sbs(image) -> (left, right)` | side-by-side 单流裁切(宽须为偶数) |
| `Detection` / `DetectionFrame` | 直接复用 `stereo_ranger.types`(纯值语义,§2.1 推荐) |
| `WarnThrottle` | 告警节流(第 1 次 + 每满 100 次,带累计计数) |
`logger` 名称为 `stereo_yolo`,走标准 `logging`。
## 单帧流程(`detect` 四步)
1. **形状一致性**:左右图形状不一致 → 整帧拒收输出空 + WARN 节流
(§2.2 第 2 条:禁止左右各自缩放/填充);
2. **左右推理**:`parallel`(默认,ThreadPoolExecutor(2),§5 硬性要求)或
`sequential`(基准对照);单目异常被隔离,该目输出空帧列表(§5 失败处理);
3. **出口自检 + 白名单**:丢弃非有限值 / `x2<=x1` / `y2<=y1` 的框
(§2.2 第 6 条);`class_whitelist` 非空时过滤类别;
4. **封装**:每目一个 `DetectionFrame(frame_id, detections)`,`frame_id`
左右共用(§2.3 同帧契约)。
## 实现决策(v1.0 冻结)
1. **ultralytics 直跑 `.pt`**(项目拍板口径):letterbox、输出解码、NMS、
框映射回原生坐标系全部由库内完成;识别端只负责阈值外置、出口自检与
契约封装。
2. **双实例池**:ultralytics 单实例非线程安全,左右并发各持一份模型
(§4 并发策略的"双实例"形态)。
3. **无跨帧状态**:前端不持有任何历史(无追踪、无平滑、无 Coast)——
结构性满足"只送本帧原生实测框"(§2.5 第 1 条)。
4. **形状不一致不 raise**:按"拒收 + 空帧 + 节流 WARN"处理,与 stereo_ranger
非同帧拒收同哲学(不阻塞整体)。
5. **几何参数单一来源**:`stereo_yolo.json` 不存内参/原分辨率(输出框天然在
输入图坐标系),F/B/cx/cy 唯一来源是 `utils/config/stereo_ranger.json`,
避免两处漂移(§4 配置一致性)。
6. **`track_id` 留 `None`**:外部追踪器注入属链路后段(§2.1;v1.1 可选)。
## 移植注记(C++ 侧需自行复现的库内行为)
本次为 ultralytics 直跑口径,以下行为在库内完成、C++ 实现必须逐项复现
(不一致会在视差取点上引入系统性偏差):
| 库内行为 | C++ 需复现要点 |
| --- | --- |
| letterbox | 等比缩放到 `imgsz`、灰边填充(ultralytics 预测默认填充值 114);左右同一套参数(形状一致时天然满足) |
| 输出解码 | YOLO26 检测头输出布局与解码(建议导出一份 ONNX 作为参照物;本仓环境:ultralytics 8.4 / torch 2.11 / onnx 1.18) |
| NMS | `conf` 先过滤、NMS 用配置 IoU 门限;`max_det` 默认 300 |
| 坐标回映 | letterbox 逆变换(scale/pad)到原生分辨率;输出 `x1,y1,x2,y2` 浮点 |
| 并发 | 双实例双线程(单实例并发调用不安全) |
## 配置项(全部外置,占位值)
| 键 | 占位值 | 说明 |
| --- | --- | --- |
| `model_path` | `models/yolo26n-drone.pt` | 相对路径按启动工作目录解析(demo 内按仓库根兜底) |
| `imgsz` | 640 | 须为 32 的整数倍(YOLO 步长约束) |
| `conf_threshold` | 0.25 | 识别端自身过滤口径(测距端不消费检测置信度,§2.4) |
| `iou_threshold` | 0.7 | NMS IoU 门限 |
| `class_whitelist` | `null` | 可选白名单;同一目标左右目类别必须一致(§2.2 第 5 条) |
| `device` | `null` | null = 自动选择(`cpu` / `cuda:0` / …) |
| `concurrency` | `parallel` | `parallel` / `sequential` |
## 测试与演示
```bash
cd utils
"D:/program file/conda/envs/pytorch/python.exe" -m pytest tests/stereo_yolo -q # 36 条
python utils/demo_stereo_yolo.py # 真模型 + 占位素材
```
单测全部用注入的假检测器驱动(无模型、无素材、无硬件):frame_id 左右共用、
出口自检丢弃退化框、白名单过滤、单目失败隔离、形状不一致拒收、双人屏障
断言真并发(串行实现会屏障超时)。
demo(真模型 + AI 合成素材 `utils/assets/stereo_yolo/drone_sky.png`):
① 单图冒烟 → ② 合成双目对(同一 patch 羽化粘贴、已知视差 12 px,实测
|ΔD|≈0.3 px、|Δy|≈0.2 px)→ 真检测框喂 `StereoRanger.update()`,记录值与
解析值 `Z=F·B/D` 同量级 → ③ 空场景(正常空帧不告警)→ ④ 形状不一致拒收
→ ⑤ 帧周期实测(台架开发机 ≈34 ms/帧,占位)。替换素材文件即可换真实图片。
## 验收对照(规格 §6)
| 项 | 状态 |
| --- | --- |
| 坐标回映误差 <0.5 px | 由库内完成,无法构件级单测;以合成对整体校验(|ΔD|≈0.3 px @1080p,含检测边缘噪声);C++ 移植后需实测 |
| 垂直对齐 P95 ≤1 px | 合成对实测 |Δy|≈0.2 px(合成口径,非真实校正质量);待真实素材 |
| 类别一致性 | 模型单类 `Drone`,合成对天然一致;待真实素材 |
| 输出合法性 | ✓ 单测(非有限值/退化框构造用例) |
| 输入纯净性 | ✓ 结构性保证(前端无跨帧状态) |
| 下游对接 | ✓ demo 场景②;δZ ∝ Z² 趋势由测距端单测覆盖 |
| 帧周期 | △ 台架开发机实测 ≈34 ms/帧(30 fps 预算 33.3 ms);待平台实测(P7) |
+20
View File
@@ -0,0 +1,20 @@
"""stereo_yolo —— 双目 YOLO 检测前端(规格:docs/双目YOLO目标识别需求说明.md)。
在左右目图像上独立运行 YOLO 检测器,输出**符合 stereo_ranger 输入契约**的
检测框列表(每目一个 DetectionFrame);不做测距、左右目关联与立体校正。
⚠ 全部参数为占位值:标定与地面验证完成前仅限台架/回放验证,不得接入实飞
控制回路(需求说明 §7 红线同源)。
"""
from .config import StereoYoloConfig, load_from_json
from .detector import UltralyticsDetector
from .frontend import StereoYoloFrontend
from .sbs import split_sbs
__all__ = [
"StereoYoloConfig",
"StereoYoloFrontend",
"UltralyticsDetector",
"load_from_json",
"split_sbs",
]
Binary file not shown.
Binary file not shown.
+86
View File
@@ -0,0 +1,86 @@
"""StereoYoloConfig 与 JSON 配置加载(需求说明 §4 配置项需求)。
全部配置外置、构造期一次加载并校验:缺项、未知字段、类型错误、非法取值
一律拒绝进入运行期(fail-fast)。几何参数(F/B/cx/cy/原分辨率)不在此
重复存储——输出框位于**输入图像自身的原生坐标系**,几何参数的唯一来源
是 utils/config/stereo_ranger.json(§4 配置一致性要求:同一配置源驱动,
杜绝两处漂移)。
"""
import json
from dataclasses import dataclass, fields
from typing import Any, Dict, Optional, Tuple
_CONCURRENCY_MODES = ("parallel", "sequential")
_INT_FIELDS = frozenset({"imgsz"})
_STR_FIELDS = frozenset({"model_path", "concurrency"})
@dataclass
class StereoYoloConfig:
model_path: str = "models/yolo26n-drone.pt"
imgsz: int = 640 # 左右共用(§2.2 第 2 条:同一缩放、同一填充)
conf_threshold: float = 0.25 # ⚠占位:识别端自身过滤口径(§2.4)
iou_threshold: float = 0.7 # ⚠占位:NMS IoU 门限
class_whitelist: Optional[Tuple[int, ...]] = None # None = 不过滤(§4 可选白名单)
device: Optional[str] = None # None = 自动选择(cpu / cuda:0 / …)
concurrency: str = "parallel" # parallel = 左右并发(§5 硬性要求,串行会吃掉全部预算)
def __post_init__(self) -> None:
_require(bool(self.model_path.strip()), "model_path", "不得为空")
_require(
self.imgsz >= 32 and self.imgsz % 32 == 0,
"imgsz",
"须为 32 的整数倍且 ≥ 32(YOLO 步长约束)",
)
_require(0.0 <= self.conf_threshold <= 1.0, "conf_threshold", "须落在 [0, 1]")
_require(0.0 < self.iou_threshold <= 1.0, "iou_threshold", "须落在 (0, 1]")
_require(self.concurrency in _CONCURRENCY_MODES, "concurrency", f"须为 {_CONCURRENCY_MODES} 之一")
_require(self.device is None or bool(self.device.strip()), "device", "须为非空字符串或 null")
if self.class_whitelist is not None:
ids = tuple(self.class_whitelist)
_require(
len(ids) > 0 and all(isinstance(item, int) and not isinstance(item, bool) and item >= 0 for item in ids),
"class_whitelist",
"须为非空非负整数列表或 null",
)
self.class_whitelist = ids
def load_from_json(path: str) -> StereoYoloConfig:
"""从 JSON 文件构建配置;缺项/未知字段/类型错误/非法取值一律抛 ValueError。"""
with open(path, encoding="utf-8") as handle:
data: Dict[str, Any] = json.load(handle)
if not isinstance(data, dict):
raise ValueError("配置非法: 顶层须为 JSON 对象")
known = {item.name for item in fields(StereoYoloConfig)}
unknown = sorted(set(data) - known)
if unknown:
raise ValueError(f"未知配置字段: {unknown}")
missing = sorted(known - set(data))
if missing:
raise ValueError(f"缺少配置字段: {missing}")
for name, value in data.items():
if name == "class_whitelist":
if value is not None and not (
isinstance(value, list)
and all(isinstance(item, int) and not isinstance(item, bool) for item in value)
):
raise ValueError("配置类型非法: class_whitelist 须为整数数组或 null")
elif name == "device":
if value is not None and not isinstance(value, str):
raise ValueError("配置类型非法: device 须为字符串或 null")
elif name in _STR_FIELDS:
if not isinstance(value, str):
raise ValueError(f"配置类型非法: {name} 须为字符串")
elif name in _INT_FIELDS:
if not isinstance(value, int) or isinstance(value, bool):
raise ValueError(f"配置类型非法: {name} 须为整数")
elif isinstance(value, bool) or not isinstance(value, (int, float)):
raise ValueError(f"配置类型非法: {name} 须为数值")
return StereoYoloConfig(**data)
def _require(condition: bool, field_name: str, message: str) -> None:
if not condition:
raise ValueError(f"配置非法: {field_name} {message}")
+78
View File
@@ -0,0 +1,78 @@
"""UltralyticsDetector —— ultralytics YOLO .pt 直载的检测后端。
⚠ 移植注记(C++ 侧需自行复现,详见 README):letterbox(缩放 + 填充到
imgsz)、输出张量解码、NMS、框映射回输入图原生坐标系,本例由 ultralytics
库内完成;移植时必须逐项复现同一几何(步长对齐、填充方式、IoU 门限、
原生坐标回映),否则视差取点会引入系统性偏差。
"""
import queue
from pathlib import Path
from typing import List
from stereo_ranger.types import Detection
from .config import StereoYoloConfig
class UltralyticsDetector:
"""双实例池:ultralytics 单实例非线程安全,左右并发各持一份(§3/§4)。
接口约定(可注入替换):``detect(image) -> list[Detection]``,
image 为 BGR ndarray(cv2 约定),返回框位于该图原生坐标系。
"""
def __init__(self, config: StereoYoloConfig) -> None:
self._cfg = config
self._model_path = _resolve_model_path(config.model_path)
self._pool: "queue.SimpleQueue" = queue.SimpleQueue()
from ultralytics import YOLO # 延迟导入:仅真实推理付加载成本,单测注入假检测器(§6)
for _ in range(2):
self._pool.put(YOLO(str(self._model_path)))
def detect(self, image) -> List[Detection]:
model = self._pool.get()
try:
results = model.predict(
source=image,
imgsz=self._cfg.imgsz,
conf=self._cfg.conf_threshold,
iou=self._cfg.iou_threshold,
device=self._cfg.device,
verbose=False,
)
finally:
self._pool.put(model)
if not results:
return []
return _result_to_detections(results[0])
def _result_to_detections(result) -> List[Detection]:
boxes = getattr(result, "boxes", None)
if boxes is None or len(boxes) == 0:
return []
xyxy = boxes.xyxy.cpu().numpy()
conf = boxes.conf.cpu().numpy()
cls = boxes.cls.cpu().numpy()
return [
Detection(
float(box[0]),
float(box[1]),
float(box[2]),
float(box[3]),
confidence=float(score),
class_id=int(class_id),
)
for box, score, class_id in zip(xyxy, conf, cls)
]
def _resolve_model_path(raw: str) -> Path:
path = Path(raw).expanduser()
if not path.is_absolute():
path = Path.cwd() / path
if not path.is_file():
raise FileNotFoundError(f"模型权重不存在: {path}(model_path 相对路径按启动工作目录解析)")
return path.resolve()
+113
View File
@@ -0,0 +1,113 @@
"""StereoYoloFrontend —— 双目检测前端主类(需求说明 §2/§3)。
单帧流程:左右图(已校正、原生分辨率)→ 左右并发检测 → 出口自检 + 类别
白名单 → 每目封装 DetectionFrame(共用 frame_id,§2.3 同帧契约)。
前端**无跨帧状态**:没有历史就没有外推,结构性满足"只送本帧原生实测框"
的输入纯净性硬约束(§2.5 第 1 条)。
"""
import logging
import math
from concurrent.futures import ThreadPoolExecutor
from typing import List, Optional, Sequence, Tuple
import numpy as np
from stereo_ranger.types import Detection, DetectionFrame
from .config import StereoYoloConfig
from .detector import UltralyticsDetector
from .warn_throttle import WarnThrottle
logger = logging.getLogger("stereo_yolo")
_EYE_LABELS = ("左目", "右目")
class StereoYoloFrontend:
"""图像 → 符合 stereo_ranger 输入契约的检测框(§2 接口契约)。
``detector`` 可注入替换:接口为 ``detect(img) -> list[Detection]``
(单测用假检测器驱动,§6 输出合法性/失败隔离用例无需真实模型)。
"""
def __init__(self, config: StereoYoloConfig, detector: Optional[object] = None) -> None:
self._cfg = config
self._detector = detector if detector is not None else UltralyticsDetector(config)
self._throttle = WarnThrottle()
self._executor = (
ThreadPoolExecutor(max_workers=2, thread_name_prefix="stereo_yolo")
if config.concurrency == "parallel"
else None
)
@property
def config(self) -> StereoYoloConfig:
return self._cfg
def detect(
self,
left_img: np.ndarray,
right_img: np.ndarray,
frame_id: int,
) -> Tuple[DetectionFrame, DetectionFrame]:
"""左右各输出一个 DetectionFrame;两帧共用同一 frame_id(§2.3)。"""
if left_img.shape != right_img.shape:
self._warn(
"shape_mismatch",
f"左右图尺寸不一致({left_img.shape} vs {right_img.shape}),整帧拒收输出空(§2.2 第 2 条)",
)
return DetectionFrame(frame_id, ()), DetectionFrame(frame_id, ())
if self._executor is not None:
left_future = self._executor.submit(self._detect_eye, left_img, _EYE_LABELS[0])
right_future = self._executor.submit(self._detect_eye, right_img, _EYE_LABELS[1])
left, right = left_future.result(), right_future.result()
else:
left = self._detect_eye(left_img, _EYE_LABELS[0])
right = self._detect_eye(right_img, _EYE_LABELS[1])
return DetectionFrame(frame_id, tuple(left)), DetectionFrame(frame_id, tuple(right))
def close(self) -> None:
"""释放并发线程池(前端不再使用时调用)。"""
if self._executor is not None:
self._executor.shutdown(wait=True)
# ---------------- 内部 ----------------
def _detect_eye(self, image: np.ndarray, eye_label: str) -> List[Detection]:
try:
raw = self._detector.detect(image)
except Exception as exc: # 单目失败隔离:不阻塞整体,该目输出空帧列表(§5 失败处理)
self._warn(f"infer_failed:{eye_label}", f"{eye_label}推理失败,该目输出空帧列表:{exc!r}")
return []
return self._sanitize(raw)
def _sanitize(self, detections: Sequence[Detection]) -> List[Detection]:
"""出口自检(§2.2 第 6 条):丢弃非有限值 / x2<=x1 / y2<=y1 的框;白名单过滤。"""
kept: List[Detection] = []
dropped = 0
whitelist = self._cfg.class_whitelist
for detection in detections:
if not _is_valid_box(detection):
dropped += 1
continue
if whitelist is not None and detection.class_id not in whitelist:
continue
kept.append(detection)
if dropped:
self._warn("degenerate_box", f"丢弃退化检测框 {dropped} 个(非有限值或 x2<=x1/y2<=y1)")
return kept
def _warn(self, category: str, message: str) -> None:
if self._throttle.should_emit(category):
logger.warning("%s(累计 %d 次)", message, self._throttle.count(category))
def _is_valid_box(detection: Detection) -> bool:
values = (detection.x1, detection.y1, detection.x2, detection.y2)
if not all(math.isfinite(value) for value in values):
return False
return detection.x2 > detection.x1 and detection.y2 > detection.y1
+16
View File
@@ -0,0 +1,16 @@
"""side-by-side 单流裁切(需求说明 §2.3:同帧拼接形态推荐优先支持)。
解码一次、裁切为左右图,天然同曝光、天然同帧;宽须为偶数。
"""
from typing import Tuple
import numpy as np
def split_sbs(image: np.ndarray) -> Tuple[np.ndarray, np.ndarray]:
"""左右各半裁切,返回 (左图, 右图)。"""
if image.ndim < 2 or image.shape[1] % 2 != 0:
raise ValueError(f"side-by-side 图像宽度须为偶数,实际 shape={image.shape}")
half = int(image.shape[1] // 2)
return image[:, :half].copy(), image[:, half:].copy()
+26
View File
@@ -0,0 +1,26 @@
"""告警节流器(需求说明 §5 失败处理):每类异常"第 1 次 + 每满 100 次"打印。
语义与 stereo_ranger 的节流器一致,但按前端/测距解耦要求(§7)不跨包复用。
"""
from typing import Dict
class WarnThrottle:
"""按异常类独立计数与限频。"""
def __init__(self, every: int = 100) -> None:
if every < 1:
raise ValueError("every 必须 ≥ 1")
self._every = every
self._counts: Dict[str, int] = {}
def should_emit(self, category: str) -> bool:
"""推进该类异常计数并返回本次是否应打印(第 1 次 + 每满 every 次)。"""
count = self._counts.get(category, 0) + 1
self._counts[category] = count
return count == 1 or count % self._every == 0
def count(self, category: str) -> int:
"""该类异常的累计次数。"""
return self._counts.get(category, 0)