Files
code-test-drone/utils/stereo_ranger/stereo_ranger.py
T

216 lines
8.7 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.
"""StereoRanger —— 双目稀疏目标测距(纯算法,规格 docs/双目测距方案.md)。
输入为左右目各自、校正后原生坐标系下的检测框列表;不做目标识别,不做
图像采集/立体校正。单帧主流程:关联(§6)→ 视差取点(§5.1)→
Z=F·B/D 与 (X, Y, R)(§4.2)→ 有效性(§5.4)→ 可选时间滤波(§7)。
依赖仅为标准库。
"""
import logging
import math
import time
from typing import Dict, List, Optional, Sequence, Tuple
from .config import StereoConfig
from .types import BBoxXYXY, Detection, RangeReport
logger = logging.getLogger("stereo_ranger")
DISP_SOURCE_EDGE = "left_edge"
DISP_SOURCE_CENTER = "center"
def focal_from_hfov(image_width_px: float, hfov_deg: float) -> float:
"""由水平视场与图像宽近似像素焦距:F = (W/2) / tan(HFOV/2)(§5.3)。"""
return (image_width_px / 2.0) / math.tan(math.radians(hfov_deg) / 2.0)
def range_error_abs(z_m: float, fx_px: float, baseline_m: float, delta_d_px: float = 1.0) -> float:
"""绝对距离误差估计 δZ ≈ Z^2·δD/(F·B)(§5.2)。"""
return z_m * z_m * delta_d_px / (fx_px * baseline_m)
def range_error_rel(z_m: float, fx_px: float, baseline_m: float, delta_d_px: float = 1.0) -> float:
"""相对距离误差 δZ/Z ≈ Z·δD/(F·B)(§5.2)。"""
return z_m * delta_d_px / (fx_px * baseline_m)
class StereoRanger:
"""双目稀疏目标测距器。
用法:每帧调用一次 update(),传入同一逻辑帧的左右目检测结果。
"""
def __init__(self, config: Optional[StereoConfig] = None):
self._cfg = config if config is not None else StereoConfig()
self._z_state: Dict[Tuple[int, int], float] = {} # (cls_id, track_id) -> 滤波 Z
self._warn_at: Dict[str, float] = {}
@property
def config(self) -> StereoConfig:
return self._cfg
def reset(self) -> None:
self._z_state.clear()
self._warn_at.clear()
def update(
self,
left: Sequence[Detection],
right: Sequence[Detection],
frame_sequence: int = 0,
timestamp_s: Optional[float] = None,
) -> List[RangeReport]:
"""单帧主流程:返回报告列表(每个左目检测一条,保持左目输入顺序)。
配对成功且测距有效的报告 valid=True;配对成功但超出有效工作域的
valid=False(距离仍给出,§5.4);未配对的左目检测 matched=False,
仅发布单目方位(§6 失败处理)。
"""
pairs = self._match(left, right)
matched_r: Dict[int, int] = {l_idx: r_idx for l_idx, r_idx, _ in pairs}
reports: List[RangeReport] = []
for l_idx, det_l in enumerate(left):
r_idx = matched_r.get(l_idx)
if r_idx is None:
reports.append(self._unmatched_report(det_l, frame_sequence, timestamp_s))
continue
report = self._build_report(det_l, right[r_idx], frame_sequence, timestamp_s)
reports.append(report)
return reports
# ---- 内部实现 ----
def _match(self, left: Sequence[Detection], right: Sequence[Detection]) -> List[Tuple[int, int, Detection]]:
"""硬约束过滤 + 代价升序贪心一一配对(§6)。返回 (左下标, 右下标, 左框)。"""
cfg = self._cfg
d_lo, d_hi = cfg.disparity_bounds_px()
candidates: List[Tuple[int, int, Detection, float]] = []
for i, det_l in enumerate(left):
for j, det_r in enumerate(right):
if det_l.cls_id != det_r.cls_id:
continue
dy = abs(det_l.cy - det_r.cy)
if dy > cfg.vertical_tau_px:
continue
h_max = max(det_l.height, det_r.height)
if h_max <= 0.0:
continue
dh_ratio = abs(det_l.height - det_r.height) / h_max
if dh_ratio > cfg.height_ratio_tau:
continue
cost = dy / cfg.vertical_tau_px + dh_ratio / cfg.height_ratio_tau
if cost >= 1.0:
continue
d_edge = det_l.x1 - det_r.x1
if not (d_lo <= d_edge <= d_hi):
d_center = det_l.cx - det_r.cx
if cfg.use_center_fallback and (d_lo <= d_center <= d_hi):
self._warn("center_fallback", "左边缘视差越出窗口,回退中心视差(宽度差可能混入)")
else:
continue
same_track = det_l.track_id > 0 and det_l.track_id == det_r.track_id
candidates.append((0 if same_track else 1, cost, i, j, det_l))
candidates.sort(key=lambda c: (c[0], c[1]))
matched_l: set = set()
used_r: set = set()
pairs: List[Tuple[int, int, Detection]] = []
for _, _, i, j, det_l in candidates:
if i in matched_l or j in used_r:
continue
pairs.append((i, j, det_l))
matched_l.add(i)
used_r.add(j)
return pairs
def _build_report(self, det_l: Detection, det_r: Detection, seq: int, ts: Optional[float]) -> RangeReport:
cfg = self._cfg
d_edge = det_l.x1 - det_r.x1
d_lo, d_hi = cfg.disparity_bounds_px()
if d_lo <= d_edge <= d_hi:
d = d_edge
source = DISP_SOURCE_EDGE
else:
d = det_l.cx - det_r.cx
source = DISP_SOURCE_CENTER
z = cfg.fx_px * cfg.baseline_m / d
report = RangeReport(
frame_sequence=seq,
timestamp_s=ts,
class_id=det_l.cls_id,
track_id=det_l.track_id if det_l.track_id != 0 else det_r.track_id,
matched=True,
valid=False, # §5.4 判定见下
forward_distance_m=z,
disparity_px=d,
disparity_source=source,
confidence=min(det_l.conf, det_r.conf),
centre_pixel_x_mid=(det_l.cx + det_r.cx) / 2.0,
centre_pixel_y_mid=det_l.cy,
ref_bbox=(det_l.x1, det_l.y1, det_l.x2, det_l.y2),
)
err = range_error_abs(z, cfg.fx_px, cfg.baseline_m, cfg.disp_noise_px)
if d >= cfg.disparity_min_px and err <= cfg.range_tol_m:
report.valid = True
z_out = self._filter_z(report.class_id, report.track_id, z)
report.forward_distance_m = z_out
report.error_est_m = range_error_abs(z_out, cfg.fx_px, cfg.baseline_m, cfg.disp_noise_px)
else:
report.error_est_m = err
self._fill_xyz(report, det_l.cx)
return report
def _fill_xyz(self, report: RangeReport, x_l_px: float) -> None:
"""由 Z 与左目(参考目)框中心像素回算左目系 X, Y, R(§4.2)。
注意:方位源 centre_pixel_x_mid 属于基线中点虚拟相机(§11.3),
而 X 定义在左目坐标系,二者相差 B/2,不得混用。
"""
cfg = self._cfg
z = report.forward_distance_m
x = (x_l_px - cfg.cx_px) * z / cfg.fx_px
y = (report.centre_pixel_y_mid - cfg.cy_px) * z / cfg.fy_px
report.lateral_m = x
report.vertical_m = y
report.slant_range_m = math.sqrt(x * x + y * y + z * z)
def _filter_z(self, cls_id: int, track_id: int, z: float) -> float:
"""可选一阶低通 + 跳变重置(§7)。track_id 为 0(无轨迹)时不过滤。"""
cfg = self._cfg
if not cfg.filter_enabled or track_id == 0:
return z
key = (cls_id, track_id)
prev = self._z_state.get(key)
if prev is None:
self._z_state[key] = z
return z
if abs(z - prev) / prev > cfg.filter_jump_ratio:
self._z_state[key] = z # 跳变:重置
return z
z_out = (1.0 - cfg.filter_alpha) * prev + cfg.filter_alpha * z
self._z_state[key] = z_out
return z_out
def _unmatched_report(self, det: Detection, seq: int, ts: Optional[float]) -> RangeReport:
return RangeReport(
frame_sequence=seq,
timestamp_s=ts,
class_id=det.cls_id,
track_id=det.track_id,
matched=False,
valid=False,
confidence=det.conf,
centre_pixel_x_mid=det.cx,
centre_pixel_y_mid=det.cy,
ref_bbox=(det.x1, det.y1, det.x2, det.y2),
)
def _warn(self, category: str, message: str) -> None:
"""节流诊断(§7:失败日志须节流)。"""
now = time.monotonic()
last = self._warn_at.get(category)
if last is not None and now - last < self._cfg.warn_interval_s:
return
self._warn_at[category] = now
logger.warning("[%s] %s", category, message)