精简仓库结构并增强任务链路:归档双目可选后端、新增LOS判据与自定义返航
归档 legacy/:stereo_yolo/stereo_ranger/target_tracker 三包 + 175 条测试 + demo/配置/权重/三份规格;运行链路不再依赖,默认 pytest 446 条,归档测试按需运行。 目标有效性第二判据改为 LOS 运动学一致性(pod_bridge/los_consistency.py),删除 require_vision;pod_locked() 叠加该判据,状态机随之后退重搜。 自定义返航(task_runner/return_home.py):保持转场高度回起飞点 → 恢复初始航向 → 垂直降落,不再爬 RTL_RETURN_ALT;未起飞/无起飞点/超时/异常仍退回 PX4 原生 RTL。 任务链安全兜底:主循环异常→紧急返航、stop 幂等、landing_stop 保留遥测、in_air 流复用+超时、解锁/起飞与吊舱识别仅执行一次、rtl_timeout 上限。 入口共享件拆到 utils/sitl_support.py;pod_sitl_test.py→l1_sitl_test.py;删除旧架构 test.py;相机脚本移入 tools/。 新增 .gitignore 并剔除历史误跟踪的 59 个 .pyc;requirements 按实际依赖重写;文档全面对齐,新增 legacy/README.md 与 tools/README.md。
This commit is contained in:
@@ -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) |
|
||||
@@ -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",
|
||||
]
|
||||
@@ -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}")
|
||||
@@ -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()
|
||||
@@ -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
|
||||
@@ -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()
|
||||
@@ -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)
|
||||
Reference in New Issue
Block a user