在机器人 SLAM / costmap / OpenVDB / 3D 避障中,传感器视锥模型的作用是描述:

当前传感器在某一时刻“能看到哪里、看不到哪里、哪些空间可以被更新为 free、哪些点可能是 obstacle”。

它通常用于:

1. 点云 / 深度图裁剪
2. Occupancy Grid 更新
3. OpenVDB / OctoMap / TSDF 融合
4. costmap clearing / marking
5. 多传感器融合中的可见性判断
6. 语义地图中的观测置信度建模

1. 视锥模型在系统中的位置

Camera / LiDAR / Depth Sensor
        ↓
Sensor Intrinsics + Extrinsics
        ↓
Frustum / FOV Model
        ↓
Visible Space Query
        ↓
Voxel Update / Costmap Update / Obstacle Fusion
        ↓
Planner / Controller

简单说:

视锥模型不是地图本身,而是地图更新时的“观测范围约束”。


2. 坐标系定义

机器人系统里至少有 3 个关键坐标系:

map
 └── odom
      └── base_link
            └── camera_link / lidar_link

常见变换:

T_map_base
T_base_sensor
T_map_sensor = T_map_base * T_base_sensor

如果一个点在传感器坐标系中为:

p_sensor = [x, y, z, 1]^T

转换到 map 坐标系:

p_map = T_map_sensor * p_sensor

反过来,如果要判断地图中的某个 voxel 是否在传感器视野内:

p_sensor = inverse(T_map_sensor) * p_map

3. 相机 / 深度相机视锥模型

对于 RGB-D 相机、双目相机、深度相机,最常用的是 针孔相机模型

相机内参矩阵:

K = [ fx  0  cx
      0  fy  cy
      0   0   1 ]

其中:

参数 含义
fx x 方向焦距
fy y 方向焦距
cx 主点 x
cy 主点 y
width 图像宽度
height 图像高度
near 最近有效距离
far 最远有效距离

3.1 像素点反投影到 3D

像素点:

u, v

深度:

z

反投影到相机坐标系:

x = (u - cx) * z / fx
y = (v - cy) * z / fy
z = z

也可以写成:

p_camera = z * K^-1 * [u, v, 1]^T

3.2 构造视锥 8 个角点

用图像四个角点分别在 nearfar 深度处反投影。

像素角点:

(0, 0)
(width, 0)
(width, height)
(0, height)

近裁剪面:

near_z

远裁剪面:

far_z

得到 8 个角点:

near_tl, near_tr, near_br, near_bl
far_tl,  far_tr,  far_br,  far_bl

示意:

camera origin
     /|\
    / | \
   /  |  \
  /   |   \
 near plane
  \   |   /
   \  |  /
    \ | /
   far plane

更准确的空间结构:

                 far_tl -------- far_tr
                  /|              /|
                 / |             / |
          near_tl -------- near_tr |
             |    |          |    |
             | far_bl -------|-- far_br
             |   /           |   /
             |  /            |  /
          near_bl -------- near_br

4. 通过 FOV 构建视锥

如果没有相机内参,也可以用水平 / 垂直 FOV 构建。

参数:

horizontal_fov = hfov
vertical_fov   = vfov
near
far

远平面半宽:

far_half_w = far * tan(hfov / 2)
far_half_h = far * tan(vfov / 2)

近平面半宽:

near_half_w = near * tan(hfov / 2)
near_half_h = near * tan(vfov / 2)

相机坐标系下一般假设:

z forward
x right
y down / up depending convention

则远平面四角可以写成:

[-far_half_w, -far_half_h, far]
[ far_half_w, -far_half_h, far]
[ far_half_w,  far_half_h, far]
[-far_half_w,  far_half_h, far]

近平面同理。


5. 判断一个 3D 点是否在视锥内

对于相机,最常用方法是:

方法一:投影判断

给定地图点:

p_map = [x, y, z]

先转到传感器坐标系:

p_sensor = T_sensor_map * p_map

得到:

X, Y, Z

然后判断深度:

near <= Z <= far

投影到像素:

u = fx * X / Z + cx
v = fy * Y / Z + cy

判断是否在图像范围内:

0 <= u < width
0 <= v < height

如果都满足,则点在视锥内。

伪代码:

bool isInsideCameraFrustum(
    const Eigen::Vector3f& p_map,
    const Eigen::Matrix4f& T_sensor_map,
    float fx, float fy, float cx, float cy,
    int width, int height,
    float near_z, float far_z)
{
    Eigen::Vector4f p(p_map.x(), p_map.y(), p_map.z(), 1.0f);
    Eigen::Vector4f ps = T_sensor_map * p;

    float X = ps.x();
    float Y = ps.y();
    float Z = ps.z();

    if (Z < near_z || Z > far_z) {
        return false;
    }

    float u = fx * X / Z + cx;
    float v = fy * Y / Z + cy;

    return u >= 0 && u < width && v >= 0 && v < height;
}

方法二:平面判断

视锥可以表示为 6 个平面:

left plane
right plane
top plane
bottom plane
near plane
far plane

每个平面:

n · p + d >= 0

如果点同时在 6 个平面的内部,则在视锥内。

适合:

大量 voxel culling
OpenVDB tree 裁剪
GPU frustum culling
局部地图范围快速筛选

6. LiDAR 视锥 / 视场模型

严格来说,2D LiDAR 不是视锥,而是 扇形扫描区域

参数:

angle_min
angle_max
range_min
range_max

判断点是否在 LiDAR 可见范围:

r = sqrt(x^2 + y^2)
theta = atan2(y, x)

条件:

range_min <= r <= range_max
angle_min <= theta <= angle_max

伪代码:

bool isInsideLidarFov(
    const Eigen::Vector3f& p_sensor,
    float angle_min,
    float angle_max,
    float range_min,
    float range_max)
{
    float x = p_sensor.x();
    float y = p_sensor.y();

    float r = std::sqrt(x * x + y * y);
    float theta = std::atan2(y, x);

    if (r < range_min || r > range_max) {
        return false;
    }

    return theta >= angle_min && theta <= angle_max;
}

7. 3D LiDAR 视场模型

3D LiDAR 可以建模为:

水平 FOV + 垂直 FOV + 距离范围

点在 LiDAR 坐标系下:

x, y, z

距离:

r = sqrt(x^2 + y^2 + z^2)

水平角:

azimuth = atan2(y, x)

垂直角:

elevation = atan2(z, sqrt(x^2 + y^2))

判断条件:

range_min <= r <= range_max
azimuth_min <= azimuth <= azimuth_max
elevation_min <= elevation <= elevation_max

这类模型适合:

Livox
Velodyne
Ouster
禾赛
速腾聚创
固态 / 半固态 LiDAR

8. 用视锥更新 Occupancy / OpenVDB

视锥在地图更新中主要有两个作用:

操作 含义
marking 传感器看到的点标记为 occupied
clearing 传感器射线经过但没有命中的区域标记为 free

8.1 点云 marking

PointCloud
    ↓
Transform to map
    ↓
Voxel index
    ↓
occupied += log_odds_hit

伪代码:

for (const auto& p_sensor : pointcloud) {
    if (!validRange(p_sensor)) {
        continue;
    }

    Eigen::Vector3f p_map = transformPoint(T_map_sensor, p_sensor);

    VoxelIndex idx = worldToVoxel(p_map);
    grid[idx].log_odds += log_odds_hit;
}

8.2 射线 clearing

从传感器原点到障碍物点之间的 voxel,都可以认为是 free:

sensor origin -------------- obstacle point
      free free free free     occupied

伪代码:

for (const auto& p_sensor : pointcloud) {
    Eigen::Vector3f origin_map = getSensorOrigin(T_map_sensor);
    Eigen::Vector3f hit_map = transformPoint(T_map_sensor, p_sensor);

    auto ray_voxels = raycastVoxels(origin_map, hit_map);

    for (size_t i = 0; i + 1 < ray_voxels.size(); ++i) {
        grid[ray_voxels[i]].log_odds += log_odds_miss;
    }

    grid[ray_voxels.back()].log_odds += log_odds_hit;
}

9. OpenVDB 中的视锥更新思路

OpenVDB 适合做稀疏 3D voxel map。

推荐设计:

Depth / PointCloud
        ↓
Frustum Model
        ↓
Ray Casting
        ↓
OpenVDB Occupancy Grid
        ↓
Projection to Costmap / SDF Query

OpenVDB 中可以存:

openvdb::FloatGrid::Ptr occupancy_grid;
openvdb::FloatGrid::Ptr sdf_grid;
openvdb::Int32Grid::Ptr semantic_grid;

其中 occupancy 可以使用 log-odds:

log_odds > threshold  => occupied
log_odds < threshold  => free
unknown               => inactive voxel

9.1 OpenVDB 伪代码

class SensorFrustum {
public:
    Eigen::Matrix4f T_map_sensor;

    float near_z = 0.2f;
    float far_z = 5.0f;

    float fx = 525.0f;
    float fy = 525.0f;
    float cx = 320.0f;
    float cy = 240.0f;

    int width = 640;
    int height = 480;

    bool containsMapPoint(const Eigen::Vector3f& p_map) const {
        Eigen::Matrix4f T_sensor_map = T_map_sensor.inverse();

        Eigen::Vector4f p(
            p_map.x(), p_map.y(), p_map.z(), 1.0f
        );

        Eigen::Vector4f ps = T_sensor_map * p;

        float X = ps.x();
        float Y = ps.y();
        float Z = ps.z();

        if (Z < near_z || Z > far_z) {
            return false;
        }

        float u = fx * X / Z + cx;
        float v = fy * Y / Z + cy;

        return u >= 0 && u < width && v >= 0 && v < height;
    }
};

OpenVDB 更新接口可以这样设计:

class VDBOccupancyMapper {
public:
    void updateWithPointCloud(
        const std::vector<Eigen::Vector3f>& points_sensor,
        const SensorFrustum& frustum)
    {
        Eigen::Vector3f origin_map =
            frustum.T_map_sensor.block<3, 1>(0, 3);

        for (const auto& p_sensor : points_sensor) {
            if (p_sensor.z() < frustum.near_z ||
                p_sensor.z() > frustum.far_z) {
                continue;
            }

            Eigen::Vector3f hit_map =
                transformPoint(frustum.T_map_sensor, p_sensor);

            updateFreeAlongRay(origin_map, hit_map);
            updateOccupied(hit_map);
        }
    }

private:
    void updateFreeAlongRay(
        const Eigen::Vector3f& origin,
        const Eigen::Vector3f& hit)
    {
        // Raycast voxel traversal
        // log_odds += miss
    }

    void updateOccupied(const Eigen::Vector3f& hit)
    {
        // log_odds += hit
    }
};

10. 投影到 2D costmap

如果要和 Nav2 costmap 结合,可以把 3D 视锥更新后的 voxel map 投影成 2D。

核心逻辑:

遍历 local costmap 范围内每个 x, y
    检查 z_min ~ z_max 内是否存在 occupied voxel
        有 → lethal obstacle
        无 → free

示意:

OpenVDB 3D Map
        ↓
z height filter
        ↓
XY projection
        ↓
2D Costmap

伪代码:

for (int mx = 0; mx < costmap_size_x; ++mx) {
    for (int my = 0; my < costmap_size_y; ++my) {

        double wx, wy;
        costmap.mapToWorld(mx, my, wx, wy);

        bool occupied = false;

        for (float z = z_min; z <= z_max; z += voxel_resolution) {
            if (vdb_map.isOccupied(wx, wy, z)) {
                occupied = true;
                break;
            }
        }

        if (occupied) {
            costmap.setCost(mx, my, LETHAL_OBSTACLE);
        } else {
            costmap.setCost(mx, my, FREE_SPACE);
        }
    }
}

11. 视锥模型在 costmap 中的作用

在 Nav2 / costmap 里,视锥主要对应:

observation source
raytrace clearing
obstacle marking
max_obstacle_height
raytrace_max_range
obstacle_max_range

示例配置:

local_costmap:
  local_costmap:
    ros__parameters:
      global_frame: odom
      robot_base_frame: base_link
      rolling_window: true
      width: 5.0
      height: 5.0
      resolution: 0.05

      plugins:
        - obstacle_layer
        - inflation_layer

      obstacle_layer:
        plugin: "nav2_costmap_2d::ObstacleLayer"
        observation_sources: scan depth_points

        scan:
          topic: /scan
          data_type: "LaserScan"
          marking: true
          clearing: true
          obstacle_max_range: 3.0
          raytrace_max_range: 4.0

        depth_points:
          topic: /camera/depth/points
          data_type: "PointCloud2"
          marking: true
          clearing: true
          min_obstacle_height: 0.05
          max_obstacle_height: 1.5
          obstacle_max_range: 2.5
          raytrace_max_range: 3.0

      inflation_layer:
        plugin: "nav2_costmap_2d::InflationLayer"
        inflation_radius: 0.4
        cost_scaling_factor: 3.0

其中:

参数 对应视锥含义
obstacle_max_range marking 最大距离
raytrace_max_range clearing 最大距离
min_obstacle_height 最小有效高度
max_obstacle_height 最大有效高度
marking 命中点标记障碍
clearing 射线清除自由空间

12. 深度相机视锥更新流程

对于 RGB-D / RealSense / Orbbec / ZED:

Depth Image
    ↓
按像素反投影
    ↓
生成点云
    ↓
视锥内点过滤
    ↓
raycast clearing
    ↓
occupied marking
    ↓
更新 voxel map / costmap

工程建议:

不要每个像素都更新

可以做:

1. depth stride 采样,例如每 2~4 个像素采样一次
2. 跳过无效深度
3. 限制 near / far
4. 限制 z 高度范围
5. 对点云做 voxel downsample
6. 使用局部 rolling map,而不是无限大地图

13. 多传感器视锥融合

如果有多个传感器:

front_depth_camera
rear_depth_camera
left_lidar
right_lidar
top_3d_lidar

每个传感器都应该有自己的视锥模型:

struct SensorModel {
    std::string name;
    std::string frame_id;

    SensorType type;

    Eigen::Matrix4f T_base_sensor;

    float range_min;
    float range_max;

    float hfov;
    float vfov;

    CameraIntrinsics intrinsics;
};

融合时:

for each sensor:
    get T_map_sensor
    build frustum
    update map

架构:

             ┌──────────────┐
             │ front camera │
             └──────┬───────┘
                    ↓
             front frustum

             ┌──────────────┐
             │  3D LiDAR    │
             └──────┬───────┘
                    ↓
              lidar fov model

             ┌──────────────┐
             │ rear camera  │
             └──────┬───────┘
                    ↓
              rear frustum

                    ↓
        Multi-sensor Visibility Fusion
                    ↓
          OpenVDB / Costmap / ESDF

14. 视锥模型的工程类设计

可以设计一个统一接口:

enum class SensorType {
    Camera,
    DepthCamera,
    Lidar2D,
    Lidar3D
};

struct CameraIntrinsics {
    float fx = 0.0f;
    float fy = 0.0f;
    float cx = 0.0f;
    float cy = 0.0f;
    int width = 0;
    int height = 0;
};

struct FrustumConfig {
    SensorType type;

    float range_min = 0.1f;
    float range_max = 5.0f;

    float hfov = 0.0f;
    float vfov = 0.0f;

    float azimuth_min = -3.14f;
    float azimuth_max =  3.14f;
    float elevation_min = -0.5f;
    float elevation_max =  0.5f;

    CameraIntrinsics camera;
};

class SensorFrustumModel {
public:
    explicit SensorFrustumModel(const FrustumConfig& config)
        : config_(config) {}

    void setPoseInMap(const Eigen::Matrix4f& T_map_sensor) {
        T_map_sensor_ = T_map_sensor;
        T_sensor_map_ = T_map_sensor.inverse();
    }

    bool containsMapPoint(const Eigen::Vector3f& p_map) const {
        Eigen::Vector4f p(
            p_map.x(), p_map.y(), p_map.z(), 1.0f
        );

        Eigen::Vector4f ps4 = T_sensor_map_ * p;
        Eigen::Vector3f ps = ps4.head<3>();

        switch (config_.type) {
        case SensorType::Camera:
        case SensorType::DepthCamera:
            return containsCameraPoint(ps);

        case SensorType::Lidar2D:
            return containsLidar2DPoint(ps);

        case SensorType::Lidar3D:
            return containsLidar3DPoint(ps);

        default:
            return false;
        }
    }

private:
    bool containsCameraPoint(const Eigen::Vector3f& ps) const {
        float X = ps.x();
        float Y = ps.y();
        float Z = ps.z();

        if (Z < config_.range_min || Z > config_.range_max) {
            return false;
        }

        const auto& cam = config_.camera;

        float u = cam.fx * X / Z + cam.cx;
        float v = cam.fy * Y / Z + cam.cy;

        return u >= 0 && u < cam.width &&
               v >= 0 && v < cam.height;
    }

    bool containsLidar2DPoint(const Eigen::Vector3f& ps) const {
        float x = ps.x();
        float y = ps.y();

        float r = std::sqrt(x * x + y * y);
        float theta = std::atan2(y, x);

        return r >= config_.range_min &&
               r <= config_.range_max &&
               theta >= config_.azimuth_min &&
               theta <= config_.azimuth_max;
    }

    bool containsLidar3DPoint(const Eigen::Vector3f& ps) const {
        float x = ps.x();
        float y = ps.y();
        float z = ps.z();

        float xy = std::sqrt(x * x + y * y);
        float r = std::sqrt(x * x + y * y + z * z);

        float azimuth = std::atan2(y, x);
        float elevation = std::atan2(z, xy);

        return r >= config_.range_min &&
               r <= config_.range_max &&
               azimuth >= config_.azimuth_min &&
               azimuth <= config_.azimuth_max &&
               elevation >= config_.elevation_min &&
               elevation <= config_.elevation_max;
    }

private:
    FrustumConfig config_;
    Eigen::Matrix4f T_map_sensor_ = Eigen::Matrix4f::Identity();
    Eigen::Matrix4f T_sensor_map_ = Eigen::Matrix4f::Identity();
};

15. 配置文件示例

可以用 YAML 管理多个传感器:

sensors:
  front_depth:
    type: depth_camera
    frame_id: camera_front_link
    range_min: 0.2
    range_max: 4.0
    intrinsics:
      width: 640
      height: 480
      fx: 525.0
      fy: 525.0
      cx: 319.5
      cy: 239.5
    update:
      marking: true
      clearing: true
      voxel_downsample: 0.05
      min_obstacle_height: 0.05
      max_obstacle_height: 1.5

  front_lidar:
    type: lidar2d
    frame_id: laser_link
    range_min: 0.1
    range_max: 12.0
    azimuth_min: -2.356
    azimuth_max: 2.356
    update:
      marking: true
      clearing: true

  top_lidar3d:
    type: lidar3d
    frame_id: lidar_top_link
    range_min: 0.3
    range_max: 30.0
    azimuth_min: -3.14159
    azimuth_max: 3.14159
    elevation_min: -0.45
    elevation_max: 0.35
    update:
      marking: true
      clearing: true
      voxel_downsample: 0.1

16. 机器人导航中推荐的视锥更新策略

16.1 对 local costmap

推荐:

近距离、高频、低延迟

参数建议:

参数 建议
update frequency 10Hz ~ 20Hz
range 2m ~ 5m
resolution 0.03m ~ 0.1m
clearing 必须开启
marking 必须开启
unknown 局部可按 free / unknown 策略处理

16.2 对 global map

推荐:

中低频、稳定、长期累积

参数建议:

参数 建议
update frequency 1Hz ~ 5Hz
range 5m ~ 30m
resolution 0.05m ~ 0.2m
log-odds 使用概率融合
dynamic objects 做时间衰减

17. 常见问题

17.1 视锥太大

问题:

错误 clearing
误删障碍物
CPU 开销大

解决:

限制 range_max
限制高度范围
只更新局部 rolling window
点云降采样

17.2 视锥太小

问题:

障碍物残留
局部地图更新慢
机器人前方 free 区域不足

解决:

增大 raytrace_max_range
检查 TF
检查相机内参
检查 FOV 配置

17.3 TF 不准

问题:

障碍物漂移
视锥方向错误
clearing 清错区域

重点检查:

map -> odom
odom -> base_link
base_link -> sensor_link

17.4 深度相机噪声导致误障碍

解决:

深度中值滤波
点云 statistical outlier removal
高度过滤
连续帧确认
log-odds 融合
小连通域删除

18. 推荐最终架构

结合 OpenVDB / costmap / SLAM,可以这样设计:

                         ┌────────────────────┐
                         │   SLAM / Odometry   │
                         └─────────┬──────────┘
                                   │
                              T_map_base
                                   │
┌──────────────┐      ┌────────────▼────────────┐
│ Depth Camera │─────▶│   Sensor Frustum Model  │
└──────────────┘      └────────────┬────────────┘
                                   │
┌──────────────┐                   │
│   3D LiDAR   │─────▶ visibility / ray casting │
└──────────────┘                   │
                                   ▼
                         ┌────────────────────┐
                         │ OpenVDB 3D Map      │
                         │ occupancy / SDF     │
                         └─────────┬──────────┘
                                   │
                    ┌──────────────┴──────────────┐
                    ▼                             ▼
          2D Costmap Projection             3D Collision Query
                    │                             │
                    ▼                             ▼
             Nav2 Local Planner          Motion Planner / VLA Safety

19. 结论

构建传感器视锥模型的核心步骤是:

1. 定义传感器内参 / FOV / range
2. 定义外参 T_base_sensor
3. 通过 TF 得到 T_map_sensor
4. 构建 near / far 平面或 FOV 判断函数
5. 判断点 / voxel 是否在视锥内
6. 对可见区域执行 marking / clearing
7. 更新 OpenVDB / OctoMap / costmap

总结:

视锥模型是传感器观测空间的几何约束,它决定哪些 voxel 可以被当前传感器更新,以及如何把点云、深度图、LiDAR 数据可靠地融合进 3D 地图和 costmap。

Logo

AtomGit 是由开放原子开源基金会联合 CSDN 等生态伙伴共同推出的新一代开源与人工智能协作平台。平台坚持“开放、中立、公益”的理念,把代码托管、模型共享、数据集托管、智能体开发体验和算力服务整合在一起,为开发者提供从开发、训练到部署的一站式体验。

更多推荐