恒美微站 Logo 恒美微站
  • 首页
  • 关于我们
  • 建站服务
  • 主题模板
  • 案例展示
  • 资讯中心
  • 联系我们

Voxblox源码解析:TSDF建图与增量ESDF实现

  • 首页
  • 资讯中心
  • /
  • Voxblox源码解析:TSDF建图与增量ESDF实现

相关资讯

Vue+ThinkPHP跨域配置全攻略:开发与生产环境实战指南 2026/9/17 12:59:46
从零构建Scratch积木渲染引擎:Canvas 2D性能优化实战 2026/9/17 12:59:46
数据中心运维外包技术方案:SLA、监控、人力测算与投标自查 2026/9/17 12:59:46

最新资讯

LLM Agent 跑多 Agent 和 Skills:Key 用 TaoToken
从PDF到LaTeX:数学公式提取与知识库构建实战
商贸公司进销存软件怎么选?从库存到应收应付的核心痛点解析
MySQL循环真相:WHILE/REPEAT/LOOP仅限存储过程
Adam优化器源码解析:公式、PyTorch实现与AdamW避坑指南
非隔离ACDC Buck电源方案:实地采样+浮地控制提升稳定性

今日推荐

每日热评|13% 的 Agent 技能带严重漏洞,这个注册表想用“验证+签名”解决信任危机
即梦AI保姆级教程:从生图到数字人,一站式搞定AI视频创作
BERT+LLM混合架构:突破NER长尾实体抽取瓶颈的工程实践

本周热门

AI SDK Harness 依赖更新指南:掌握 harness 包 SDK 依赖的升级、桥接同步与一致性校验
Refine v5 Ant Design NumberField 组件实战:基于 Intl 的本地化数字格式化
Flutter应用改名全指南:从Android到iOS的配置与工具实践

本月精选

自研推理加速器Redwood:两周内实现PyTorch模型高效部署的实战教程
V4L2摄像头采集实战:从camera_client.rar到出图全流程解析
从“谁发明了钢琴键”到知识问答智能体:RAG与记忆工程实践

Voxblox源码解析:TSDF建图与增量ESDF实现

发布时间:2026/9/17 12:59:46
Voxblox源码解析:TSDF建图与增量ESDF实现 做三维建图这块的朋友,大概率都在某个时间点被Voxblox这个名字撞过一下。它不算是那种下载即用的黑盒工具,而是一个把TSDF 建图和增量式 ESDF揉在一套代码里的开源库,出自 ETH Zurich 的 ASL 实验室,最早是为一台小型飞行器的机载规划服务的——因为机上的计算资源少得可怜,传统那种先把地图建完,再离线算一遍欧氏距离场的做法根本来不及。我前后断断续续读了几轮它的源码,也拿它跑过几段深度相机和激光雷达的数据,这里把读代码的过程、被绕进去的地方、以及后来想明白的道理整理成一份阅读笔记,给准备上手或者正在啃这份代码的人省点时间。它适合三类人:想搞清楚 TSDF/ESDF 到底怎么落地的、想在自己的项目里接一个体素地图的、以及想基于它做二次开发(语义、动态物体、自己的规划器)的。内容会从整体结构一路讲到参数怎么定、坑在哪里。1. 先搞清楚 Voxblox 到底解决什么问题1.1 一句话定位:TSDF 建图加增量 ESDFVoxblox干的事可以拆成前后两段。前一段是体素化的 TSDF 建图:把深度图或者点云,沿着射线插进一个稀疏的体素网格里,每个体素记录一个到最近表面的带符号距离以及一个这个值有多可信的权重。后一段是把这个 TSDF 转成 ESDF,也就是欧氏带符号距离场,让每个自由空间的体素知道自己离最近的障碍物有多远。后者是规划算法最爱用的东西——因为距离场的梯度天然指向远离障碍的方向,不管是采样式规划器还是基于梯度的轨迹优化,都能直接拿它做碰撞代价。为什么要分成两段,而不是直接建 ESDF?这是理解整个库的关键。TSDF 的更新是纯局部的:来一帧新数据,只有射线经过的那些体素需要改,复杂度跟传感器视野成正比,跟地图大小无关。而 ESDF 是全局耦合的:一个体素的距离值取决于它周围一圈的所有体素,动了任何一个表面,理论上附近一大片值都要跟着变。Voxblox 的做法是——TSDF 用局部增量更新,保证实时性;ESDF 用一个受限半径的波前传播(BFS 那一套)做增量更新,只在一个max_esdf_distance的带宽内维护精确值,带宽外直接标成未知或者饱和值。这个取舍是整个库的灵魂,读代码的时候带着这个前提去看,很多看起来莫名其妙的判断就都能解释了。1.2 它相比 OctoMap、VDBFusion 的取舍在哪选型这件事值得单独说一段,因为它直接决定了你该不该用 Voxblox。先说OctoMap:八叉树做占据栅格,内存效率高、概率更新有理论支撑,但它给你的是这块是障碍、那块是空闲,给不了连续的距离和梯度。规划器拿到占据栅格只能判断撞/不撞,想要离障碍多远就得自己再跑一遍距离变换。其次是VDBFusion和基于 OpenVDB 的那些方案:它们把 TSDF 塞进稀疏的层级数据结构里,内存压缩做得非常漂亮,适合大场景离线重建,但它们的设计目标偏向重建出好看的网格,在线增量能力和查询接口不是重点。Voxblox 的位置很明确:它不追求极致的重建质量,也不追求极致的内存压缩,它追求的是每帧都能算完、随时能查、能直接喂给规划器。所以你会看到它用了一个固定尺寸的体素块(block)加哈希表来管稀疏性,而不是八叉树或者层级 VDB——因为在块级别做增删改查的常数是最小的,哈希表查一个块就是一次 O(1)。代价是内存粒度粗:哪怕一个块里只有一个体素被观测到,整个块也要分配出来。我第一次看到这个设计的时候觉得挺浪费,后来算了一下账才发现,当体素大小取 5 cm、块边长取 0.8 m 的时候,块级别的浪费相对整块内存来说是可以接受的,换来的是索引速度快了一个数量级。如果你的需求是离线跑一整天数据,重建一个几万平米的精细模型,那 Voxblox 大概率不是最优解;但如果你是机载/车载实时建图,边建边规划,它是目前最顺手的选择之一。1.3 读之前需要准备的基础和工具先说前置知识。Eigen必须熟,整个库的坐标变换、点、矩阵全靠它,Transformation就是Eigen::Isometry3d的包装。ROS 里tf的坐标变换约定也要清楚,尤其是T_G_C这种从相机系到全局系的写法,读反了会调试到崩溃。C 层面需要懂模板特化和模板递归——LayerVoxelType那一套是典型的 CRTP 风格,派生类通过DerivedVoxelType反查自己要干什么。工具上建议这么搭:源码按分支拉下来,用 CMake 正常编译;调试的时候不要一上来就跑 ROS 节点,先用voxblox_ros里那套离线工具或者自己写一个小的main去喂pcl格式的点云。原因很简单——ROS 节点里掺了订阅、消息转换、线程池,断点打进去会跳得你怀疑人生。我自己最开始就是直接在节点里加 print,结果一帧数据打出来几千行,完全没法看。后来改成先把一帧点云读成pcl::PointCloudPointType,手动构造Transformation,调integrator-integratePointCloud(),断点就能稳稳停在该停的地方了。另外强烈建议一边读一边画图。Voxblox 有两套索引系统——全局体素索引和块索引加块内索引,还有一套世界坐标。这三者之间的换算散落在Layer的十几个函数里,不画一张转换图,过两天就忘。2. 代码骨架构:一个 Layer 撑起所有地图类型2.1 Layer、Block、Voxel 三层结构整个库的内存结构是三层:Voxel(体素)是最小单元,里面存数据;VoxelBlock(块)是一组voxels_per_side³个体素的连续数组,默认voxels_per_side 16,也就是一个块 4096 个体素;Layer是块的管理者,内部维护一个哈希表和一个指针数组。为什么用哈希表 指针数组这套双结构?因为这两种访问模式的性能需求是矛盾的。按坐标查块是随机访问,需要哈希表的 O(1);但不管是做集成、跑 ESDF 传播、还是生成网格,都需要遍历所有块,这时候哈希表的迭代效率低得可怜,而且指针散落在堆上,缓存命中率极差。所以Layer里同时存了:一个unordered_map,键是BlockIndex(三个 int 组成的块坐标),值是指向块的指针;一个vectorBlockPtr,每个新分配的块都往里 push 一个指针。遍历走 vector,查块走 map。唯一的代价是删除块的时候两边都要维护,所以你能看到那个removeBlock函数里小心翼翼地同时操作两个容器。这个套路在机器人领域的地图库里面很常见,记住它就行。2.2 块索引、哈希与负坐标那几个坑索引转换是这份代码里最容易读错的部分,我把它列成一张表:概念含义典型计算方式世界坐标米为单位的连续坐标传感器直接给的全局体素索引以体素为单位的整数坐标,可以是负的floor(coord / voxel_size)块索引以块为单位的整数坐标global_voxel_idx / voxels_per_side块内索引0 到voxels_per_side-1的局部索引global_voxel_idx % voxels_per_side看起来简单,坑全在负数上。C 的整数除法是向零截断的,-1 / 16得到 0,而你要的是 -1(向下取整)。所以代码里必须手写一个向下取整的除法,大概长这样:// 示意:负坐标必须手动向下取整,否则块索引会串位 BlockIndex getBlockIndexFromGlobalVoxelIndex(const GlobalIndex idx, int vps) { auto floor_div [vps](int v) { return (v 0) ? (v / vps) : ((v - vps 1) / vps); }; return BlockIndex(floor_div(idx.x()), floor_div(idx.y()), floor_div(idx.z())); }同理,块内索引也要处理负数:((global_idx % vps) vps) % vps才是安全的。我当初在这里吃过一次亏:在一个跨越原点的小场景里测,结果 x 方向的体素全都对不上,查了半天才发现是取模的符号问题。只要你的地图跨越坐标原点,这段代码就必读。哈希函数本身用的是经典的素数异或混合:// 示意:三个坐标各乘一个大素数再异或,是为了让相邻块散开 size_t h (size_t)(x * 73856093) ^ (size_t)(y * 19349663) ^ (size_t)(z * 83492791);这里没什么玄学,三个素数越大越互质,分布越均匀。真正需要注意的是哈希表会重新散列:当块数量从几千涨到几万的时候,unordered_map的 rehash 会造成一次明显的卡顿。如果你在跑长时间的数据,看到某一帧的耗时突然跳了十倍,先别怀疑算法,去看看是不是块数量刚好跨过了一个扩容阈值。2.3 TsdfVoxel 和 EsdfVoxel 里到底存了什么这两个结构体是整个库最核心的数据定义,值得逐字段抠。TsdfVoxel里面通常有这么几项:distance,带符号距离,正数表示在自由空间(表面前方),负数表示在物体内部;weight,累积权重,用来做加权平均以及判断这个体素可信不可信;color,如果你开了彩色融合,这里是 RGB;有些版本还会塞一个梯度字段,用于插值。字段少,但每个都影响后面一整串逻辑。比如网格生成时会用weight过滤掉权重太低的体素——因为这些体素的距离值只是被截断的估计,拿去做 marching cubes 只会生成一堆噪声面片。EsdfVoxel的字段更多一些,常见的有:distance、observed(是否被观测过,没观测过的是未知区域)、in_queue(是否已经排在波前传播的队列里,防止重复入队)、fixed(值直接来自 TSDF,不需要被波前改写)、以及可选的梯度。这里observed和fixed两个布尔的组合是理解 ESDF 传播的关键:一个体素可能是被观测过但不在固定带上,也可能是没被观测但被邻居推出来一个距离。规划器查询的时候要区分这两种情况,否则会把推出来的估计值当成真实测量,在未知区域边缘做出很激进的决策。关于内存占用,我实际算过一笔账:体素大小 0.05 m,voxels_per_side 16,那么一个块的边长是 0.8 m,含 4096 个体素;单个 TSDF 体素大约十几到二十几个字节(取决于是否带颜色和梯度),加上块本身的对齐和头部开销,一个块大概在64 KB 到 128 KB之间。一个 10 m × 10 m × 3 m 的室内场景,按表面面积 300 m² 估算,表面穿过的块数大概是 300 / 0.64 ≈ 470 个,再把表面附近被观测到的自由空间块算进去,实际规模通常在1000 到 2000 个块,也就是一两百 MB。这个数字就是为什么你必须限制max_ray_length_m:不限制的话,一条 30 m 长的射线会一路把沿途所有块都分配出来,内存直接翻好几倍。3. TSDF 积分:从深度图到一个带权重的距离场3.1 投影式 TSDF 的距离到底怎么算出来的这是整个库最需要想明白的一段数学。给定一个体素中心 $V$、一个传感器原点 $O$、以及一个观测到的表面点 $P$(P是深度图上的一个像素反投影出来的三维点),我们要算的是这个体素沿着射线方向,离表面有多远。教科书上的 TRUNCATED SDF 一般用点到平面的距离,但那是假设你有一个已知的表面法向。实时建图里没有法向,只有沿射线的观测,所以 Voxblox 用的是投影式距离:把体素中心投影到射线上,用射线的长度减去投影长度,作为带符号距离。写成代码大概是这样:// 示意:投影式 TSDF,先算射线方向,再把体素中心投影上去 Point ray_dir P - O; // 从原点到表面点 FloatingPoint point_dist ray_dir.norm(); // 表面点沿射线的距离 Point voxel_vec V - O; FloatingPoint voxel_proj ray_dir.dot(voxel_vec) / point_dist; // 体素中心在射线上的投影 FloatingPoint sdf point_dist - voxel_proj; // 正数表示体素在表面前方这个式子有个很漂亮的几何直觉:当体素中心正好落在表面上时,sdf 0;体素在表面前方(靠近相机一侧),sdf 0;跑到表面后面,sdf 0。它的符号约定和我们平时想的正数在物体内部是反的,这一点在接规划器的时候特别容易搞反,一定要在代码里确认清楚再往下写。然后就是截断:如果sdf truncation_distance,这个体素太靠前了,对重建没意义,直接跳过;如果sdf -truncation_distance,那这个体素在物体深处,如果没有合理的清除逻辑,也跳过。只有落在[-truncation, truncation]这个薄带里的体素才会被更新。这就是TSDF里那个 T 的全部含义,也是为什么体素大小和截断距离必须匹配——截断距离比体素大太多,薄带就穿过了好几层体素,精度糊;比体素还小,薄带可能一层体素都覆盖不到,表面就断断续续。3.2 权重设计:为什么不是简单地取平均新来的观测和已有的值怎么融合?最朴素的想法是取平均,或者按帧数递减加权。Voxblox 用的是加权移动平均:// 示意:加权融合,权重用来抑制远处和噪声观测的影响 voxel.distance (voxel.distance * voxel.weight sdf * w) / (voxel.weight w); voxel.weight std::min(voxel.weight w, max_weight);关键在那个w怎么给。远距离的观测误差大(深度相机在 5 m 外的噪声可能是 5 cm 级别,和整个体素一样大),所以权重应该随距离衰减。常见的两种做法是w 1 / (ray_length * ray_length)(距离平方反比)或者干脆w 1(常数权重)。配置里那两个开关use_const_weight和use_weight_dropoff就是控制这个的。我实测下来的体会是:如果你用的是结构光或者双目深度相机,开距离平方反比明显更好,因为它的噪声确实随距离爆炸;但如果你用的是已经做过降采样的激光点云,误差基本不随距离变化,这时候用常数权重反而更稳,不然近处的观测会被赋予过高的权重,后面来一帧带噪声的远距离数据根本改不动它。max_weight这个上限也很重要,它保证一个体素不会因为被反复观测而变得永远正确——现实里物体是会动的,一个不可更新的体素等于给自己埋雷。默认值通常给到 100 左右,场景里有动态物体的话可以降到 10 到 30,让旧数据被更快地覆盖掉。3.3 清空逻辑与三种 Integrator 怎么选清除(clearing)这一段值得单独拎出来,因为它经常被忽略,却直接影响地图能不能用。逻辑是这样的:如果射线穿过了某个体素,而这个体素在表面之前的位置(也就是说sdf是正的,而且超过了截断距离),那这个体素一定是自由空间,应该被明确标记为空。不写这段,你的地图上就会残留一堆从来没被更新过的体素,规划器一看全是未知,什么都不敢走。Voxblox 提供了三种积分器,读代码的顺序必须是从简单到复杂:积分器思路适用读代码的顺序Simple逐点、逐体素,沿射线步进更新参考实现,慢但逻辑最干净第一遍读这个Merged先把邻近点合并,减少重复射线点云密度高时收益明显第二遍Fast每个点直接算出影响范围内的体素数,多线程切分在线实时第三遍,了解优化手段Simple那个版本我是逐行读的,大概两百行,把射线步进 投影 加权融合 清除四件事写得明明白白。Fast版本就跳跃多了:它不再沿着射线步进,而是对每个点算出一个包围盒,遍历盒里的体素,判断哪些被这条射线影响;同时用 OpenMP 把点云切块并行。看它的时候不要在数学上纠缠,重点看它怎么切分任务、怎么处理线程安全——答案是利用不同线程写的体素大概率不重叠这个事实,加上块级别的粗粒度锁,而不是给每个体素加锁。这是个很实用的工程技巧:锁的粒度大到一定程度,冲突概率就低到可以接受,而开销远小于细粒度锁。4. ESDF 增量更新:BFS 波前传播的实现细节4.1 为什么不能直接对 TSDF 求梯度当 ESDF 用这是个常见的误解,我一开始也以为 TSDF 的梯度就是 ESDF 的梯度。不对。TSDF 只在表面附近的薄带里有意义,薄带外面是截断的常数;它的梯度在薄带里确实近似指向法向,但它的数值不是到最近表面的欧氏距离,而是沿射线方向的投影距离。在斜视角、或者在物体内部的凹陷处,这两个值差得很远。ESDF 要的是真正的欧氏距离。它的一个关键性质是:梯度模长恒为 1(除了在距离场的脊线上)。这个性质是规划器用得最爽的地方——它天然是一个归一化的方向向量,可以直接当作远离障碍的推力方向。所以 Voxblox 必须专门跑一遍传播算法,把 TSDF 转成 ESDF,而不能偷懒。4.2 从 TSDF 初始化到波前传播传播的流程可以拆成三步,理解了这三步,EsdfIntegrator那几百行代码就通透了。第一步,初始化固定带。遍历这一批更新过的 TSDF 块,对每个体素判断:如果它被观测过(权重非零),而且|tsdf_distance| truncation,那它就是表面带体素,直接把 TSDF 的值抄过去当 ESDF 的初值,标记observed和fixed,然后把这个体素的邻居推进队列。这些体素的值来自真实观测,不参与后续的松弛更新。第二步,把邻居入队。注意这里入队的是表面带体素的邻居,而不是表面带体素本身。因为表面带自己已经有正确值了,需要被推出来的是它外面那一圈——那些 TSDF 被截断了、真正值只能靠传播算出来的体素。第三步,波前松弛。从队列头取一个体素,对它周围的邻居算:候选距离 当前体素的距离 两者之间的几何距离。如果候选值比邻居现有的值更小,并且候选值不超过max_esdf_distance,就更新邻居并把它推进队列:// 示意:波前传播的核心松弛步骤 for (const auto [offset, dist] : neighbors) { EsdfVoxel* n getVoxelByOffset(v, offset); if (!n) continue; FloatingPoint candidate v-distance dist * voxel_size; if (candidate n-distance - kEpsilon candidate max_esdf_distance) { n-distance candidate; if (!n-in_queue) { open_list.push(n); n-in_queue true; } } }这里有几个细节是读代码时最容易被忽略、但恰恰最关键的地方:kEpsilon不能省。浮点比较如果写成candidate n-distance,两边在数值上接近时会出现更新一点点、再被反向更新一点点的振荡,队列永远清不空。加一个 1e-6 量级的容差,只有明显更优才更新,是这类算法能收敛的保证。邻居模板决定精度和开销。6 邻域的传播只能走直线,算出来的距离在斜方向会偏大,形状像个菱形;26 邻域能走对角,精度高得多,但每个体素要检查 26 次。18 邻域是折中。这里的距离权重要用1、√2、√3乘以体素尺寸来区分,否则对角方向的传播会退化成曼哈顿距离。这是个近似算法。即使 26 邻域,离散网格上的波前传播得到的也是棋盘距离的近似,和真正的欧氏距离变换有百分之几的误差。做避障够了,但如果你要拿它算精确的最短路径长度,得心里有数。4.3 EsdfIntegrator 和 EsdfMap 两条路线的差异这是我读第二遍的时候才真正搞明白的一处设计。Voxblox 里其实有两套 ESDF 实现,思路完全不同,踩过的坑也都写在脸上。第一套是刚才讲的EsdfIntegrator,基于队列的波前传播。它的优势是快——一次传播的复杂度和受影响的体素数量成正比,不需要优先队列。但它有一个天生的短板:处理不了距离变小的情况。想象一个障碍物从场景里被搬走,原来离它很近的体素,ESDF 值应该变大;可波前传播的松弛只在候选值更小时更新,值只会降不会升。结果就是地图上永远留着一个幽灵障碍。你在跑带动态物体的数据集时看到规划器莫名其妙绕开一片空地,八成就是这个原因。第二套是后来加的EsdfMap,用的是优先队列加惰性删除的思路,同时支持降低和抬升。它的做法更接近经典的 EDT 算法:每次更新把受影响的体素丢进优先队列,按当前估计的距离排序,弹出最小值,再用它去更新邻居。抬升则是靠重新初始化受影响区域来实现。代价是每次更新的分支更多、常数更大,但它能正确处理动态场景。选哪一个,取决于你的场景里物体动不动。静态场景用前者,性能好;有动态物体,或者你要做清除一块区域这类操作,老老实实用后者。写配置的时候一般是加一个开关来做选择,具体参数名各版本略有出入,以你手上的分支为准。我的建议是:先把EsdfIntegrator跑通,理解传播的机制,再切到EsdfMap上对比同一段数据的结果——你会发现动态物体区域的表现差异非常直观,比看论文有效得多。5. 把它跑起来:参数、配置与实操记录5.1 编译与数据准备编译这块没什么特别的坑,但有几个依赖版本问题值得提前说。Eigen 建议用系统包管理器装的版本,别自己从源码编译,cereal、glog、protobuf 这些传递依赖容易因为版本不一致产生奇怪的链接错误。ROS 环境下编译的时候注意-DCMAKE_BUILD_TYPERelease,这个库对编译优化的依赖非常大——我犯过一次错,用 Debug 跑,单帧耗时从 20 ms 涨到了 300 ms 以上,一开始还以为是参数配错了。数据准备方面,最省事的路径是直接用点云而不是原始深度图。因为原始深度图要处理相机内参、深度图到点云的转换、畸变、以及针孔模型的反投影,一层套一层,调试起来噪音太大。把一帧深度图离线转成pcl::PointCloudpcl::PointXYZI存成 pcd,读进程序里手动构造变换矩阵喂进去,能把变量减少一半。等单帧通了,再接 ROS 的实时流水线。5.2 关键参数怎么定,以及为什么这一节是我觉得最值得写下来的部分,因为官方配置里给的是一堆数字,没告诉你为什么。我按自己试出来的思路整理一遍:体素大小voxel_size。这是总开关,其他参数都得跟着它走。经验值是 0.02 到 0.10 m 之间。选它的时候先问自己一个问题:我的规划器需要多精细的避障?如果要穿门、穿走廊,门宽在 0.9 m 左右,那体素取 0.05 m 对应 18 个体素的宽度,足够了;如果只是开阔场地的粗避障,0.10 m 也够用。代价是内存的反平方关系——体素减小一半,同样体积的体素数翻 8 倍,块数也差不多翻 8 倍。截断距离truncation_distance。一般取体素大小的 3 到 4 倍。取 0.05 m 的体素就配 0.15 到 0.20 m。为什么不能更小?因为太小的话,薄带可能都盖不住一个完整的体素层,表面出现空洞;为什么不能更大?因为太大就会把离表面较远的体素也写进距离值,而这些值本身是投影近似,误差大,会污染后面的网格生成。射线长度max_ray_length_m。这个是内存控制阀。室内场景给 3 到 5 m,室外给 8 到 15 m,但一定要给,别设成无穷大。我试过不设上限,跑一段走廊数据,内存从 200 MB 直接涨到 1.5 GB,因为每条射线都在一路分配新块。另一个容易忽略的是min_ray_length_m,一般给 0.1 到 0.3 m。为什么需要下限?因为深度相机在极近距离(比如 5 cm)会给出完全不可信的读数,而且机器人自身结构可能被拍进去,这些观测必须过滤掉。最大 ESDF 距离max_esdf_distance。这个参数直接决定 ESDF 传播的计算量和内存。给 2 m 和给 10 m,传播的体素数量差几十倍。规划上其实用不到很远的距离——离障碍 5 m 开外的地方,梯度方向也没那么重要了,因为那里根本不会碰撞。我的习惯是给 2 到 3 m,只有做那种需要大范围引导场的规划器时才加到 5 m 以上。权重相关的三个开关。use_const_weight、use_weight_dropoff、max_weight,前面 3.2 节说过了,这里补一句:如果场景里有动态物体,除了降低max_weight,还应该考虑缩短max_ray_length_m,让远处的旧数据更快被新数据覆盖。5.3 一次完整的离线跑图记录我把一次实际调试的过程记下来,因为参数怎么调,看数字不如看过程。第一次跑,参数是体素 0.05 m、截断 0.15 m、最大射线 10 m、26 邻域、max_esdf_distance3 m。结果:TSDF 出来的网格表面还行,但 ESDF 的耗时会随着数据帧数线性增长,跑到第 300 帧的时候单帧已经要 200 ms 以上了,完全不能在线用。排查思路是先在 ESDF 更新那一步打计时,发现耗时主要在遍历更新过的块这一环,而不是在波前传播本身。原因很快找到了:我用的是点云输入,每一帧的视野很大,几乎覆盖了整张地图,于是每帧都把所有块标记成已更新,传播被反复触发。改成两个措施:一是把max_ray_length_m从 10 m 降到 5 m,视野缩小到局部;二是把max_esdf_distance从 3 m 降到 2 m。改完单帧 ESDF 更新降到了 20 到 40 ms,已经能配合 10 Hz 的传感器了。这里得到的经验是:ESDF 的耗时不是跟建图范围成正比,而是跟每帧被标记为更新的范围成正比,所以控制视野比控制地图大小有效得多。第二个问题是网格生成时有明显的噪声面片,尤其是在一面白墙前面。这个原因更隐蔽:白墙的深度估计误差大,而且我在近距离(0.3 m 以内)没有做过滤。加上min_ray_length_m 0.2之后好了很多。另外一个原因是网格生成时没有过滤权重,默认的mesh_min_weight太低,导致被截断的、只观测了一两次的体素也参与了 marching cubes。把权重阈值提上去之后,噪声面片基本消失了。第三个问题是坐标对不上。这个折腾了最久:我拿的是激光雷达数据,tf里雷达系到基座系有个小旋转,我图省事手动填了一个单位矩阵,结果地图整体歪了大概 5 度。这个错误最恶劣的地方在于它不会报错——地图看起来是正常的,只是缓慢地漂,你可能跑了几百帧才发现墙面在动。教训是:变换矩阵千万不要手填,老老实实从tf里查,查不到就报错退出,宁可程序崩掉也不要生成一张悄悄歪掉的地图。6. 踩坑记录与排查速查表6.1 内存与性能类问题内存持续增长不收敛。先查max_ray_length_m有没有设,再查是不是所有块都被反复分配。有一个隐蔽的原因:哈希表里删掉的块没有真正释放。有些版本在移除块的时候只是从哈希表里 erase,指针还留在遍历用的 vector 里,如果你的场景里有大量动态障碍物反复出现和消失,内存就会一直涨。检查方法是打印块数量和实际内存占用,如果两者对不上,基本就是这个原因。单帧耗时突然跳变。前面提过哈希表 rehash,还有一个是 OpenMP 线程数。默认线程数跟着 CPU 核数走,在共享的机器上会互相抢核,表现是耗时抖动特别大。手动把线程数固定成一个合理值(比如 4 到 8),稳定性会好很多,即使峰值性能略有下降。ESDF 更新比 TSDF 还慢。这通常说明你的max_esdf_distance给大了,或者是每帧的更新范围太大。前者降参数,后者控制传感器视野或者做降采样。6.2 数据与坐标类问题地图整体偏移或者旋转。九成是变换矩阵的问题。检查顺序:传感器到基座的静态变换、基座到全局系的定位输出、以及时间戳是否对齐。时间戳这件事值得单独强调:如果点云和位姿的时间戳差了几十毫秒,机器人走得快一点,地图就会糊掉一层,而且肉眼很难判断。我一般会在集成前打印两者的时间差,超过阈值就丢掉这一帧,宁可少建一点。未知和空闲分不清。规划器把未知区域当成空闲,一头撞进没建过的房间。这是 ESDF 里observed标记没被正确使用导致的。查询接口返回的是距离值,一定要同时读observed,未观测的体素不能当自由空间用。近距离噪声导致表面长毛。前一小节说过,设置min_ray_length_m加上网格权重阈值,基本能解决。6.3 常见问题速查表现象最可能的原因处理方式表面出现空洞截断距离小于 3 倍体素截断距离调到 3~4 倍体素大小表面长毛、噪点近距离噪声、网格权重阈值太低设最小射线长度,提高网格权重阈值内存爆炸未限制最大射线长度限制到 5 m 以内(室内)ESDF 更新慢max_esdf_distance或每帧更新范围过大降到 2~3 m,控制视野动态物体留下幽灵障碍波前传播无法抬升距离值换用支持抬升的 ESDF 实现地图缓慢漂移坐标变换错误或时间戳不同步从变换树查询,检查时间戳对齐单帧耗时抖动大哈希表扩容、线程抢占固定线程数,关注块数变化未知区域被当空闲未使用observed标记查询时区分未知与空闲7. 二次开发可以往哪几个方向改7.1 想做语义或者动态物体,从哪下手如果你想在地图上叠语义标签,最省事的做法是在TsdfVoxel里加一个标签字段,集成的时候根据点云的标签做投票。这里有个工程上的坑:体素级别的标签投票和距离的加权平均不能用同一个权重。距离是几何量,融合权重应该反映测量精度;标签是离散的,融合应该是多数投票或者概率累积。我见过有人直接把weight拿去做标签投票,结果远处的观测几乎不起作用,近处的一两个噪声点就能决定一个体素的标签,效果很差。动态物体的话,路线会不太一样。最直接的是在集成前做点云级的前景剔除,用聚类或者学习的方法把动态点滤掉,让它们根本不进地图。另一条路是在地图层做,给体素加时间戳,自动衰减旧观测——但这条路要求 ESDF 能抬升,也就是必须用支持抬升的那套实现,否则删了 TSDF 值,ESDF 还留着,等于白干。我试过第一条路,实现简单,效果也够用,代价是依赖前端的分割质量。7.2 想接自己的规划器,接口怎么用Voxblox 的查询接口主要有三个:按坐标查本体素的距离值、按连续坐标做三线性插值取距离、以及取距离和梯度。做避障规划一定要用插值版本,因为体素是离散的,按体素查询会在体素边界上产生台阶,规划器算出来的梯度会在台阶处跳变,轨迹会抖。梯度这块有个细节:如果直接对三线性插值的结果做有限差分,得到的场是分段线性的,梯度在体素边界不连续,规划器会感受到轻微的不平滑。更讲究的做法是用插值的解析导数,虽然计算量稍大,但场的连续性更好。这个差别在低速规划上看不出来,在需要输出平滑轨迹的场景里(比如给轨迹优化器提供代价和梯度),差别挺明显。我的建议是:先用有限差分版本跑通,如果轨迹有明显的抖动再换解析导数,不要一上来就优化这个。还有一个容易踩的坑是ESDF 的距离饱和。max_esdf_distance之外的体素值会被设成一个常数,如果你直接把这个常数拿去算代价,规划器会认为这里到处都是同样的距离,梯度为零,引导效果消失。正确的做法是对饱和区域做特殊处理,要么当成不可用的未知区域,要么在代价函数里单独设计。我在实际使用中最大的体会是,这份代码真正的难点不在任何一个单独的算法上——投影 TSDF、波前传播、marching cubes,单拎出来都是有教科书答案的东西。难的是把它们组合成一个能吃实时数据流、内存可控、查询接口稳定的系统,而这里面的每一个取舍都藏在配置项和那几行看起来多余的判断里。所以读的时候别急着跳到 ESDF 那一章,把Layer的索引换算和SimpleTsdfIntegrator老老实实读完,后面会省下大量为什么结果不对的时间。另外一个小技巧:自己写一个极简的测试程序,只往地图里塞一个立方体的点云,然后把 TSDF 和 ESDF 的值打印出来看,比读十遍源码都管用——因为你立刻就能看出符号约定、截断行为和传播结果是否符合预期。

关于恒美微站

恒美微站专注于为个体商户、工作室提供极简自助建站服务,让每个人都能轻松拥有专业网站。

快速链接

  • 关于我们
  • 建站服务
  • 主题模板
  • 案例展示
  • 资讯中心

服务项目

  • 可视化建站
  • 拖拽编辑
  • 主题定制
  • SEO 优化
  • 网站托管

联系方式

  • 📍 地址:北京市朝阳区建国路 88 号
  • 📞 电话:400-888-8888
  • ✉️ 邮箱:info@hmyw.cn
  • 🕐 时间:周一至周日 9:00-18:00

© 2024 恒美微站 hmyw.cn 版权所有 | 京 ICP 备 12345678 号