云法向量估計(jì)的體素化方案:從PCL常規(guī)思路到大規(guī)模場景實(shí)戰(zhàn))
做點(diǎn)云處理這么多年我一直覺得法向量估計(jì)是“最容易忽視但最影響后續(xù)效果”的一步。很多同學(xué)一開始接觸 PCLPoint Cloud Library上來就是pcl::NormalEstimationK近鄰一設(shè)、法向量一出看著可視化里五顏六色的線條還挺開心結(jié)果一去做 ICP 配準(zhǔn)或者移動(dòng)立方體重建各種問題就開始冒出來。后來我在處理大規(guī)模場景點(diǎn)云時(shí)逐漸摸出一套“體素法向量Voxel-based Normal”的做法不去糾纏每個(gè)點(diǎn)單獨(dú)找鄰居而是先把點(diǎn)云按空間切成小格子再讓每個(gè)格子里的點(diǎn)一起“投票”算出朝向。今天這篇文章就把這套方法徹底講透。如果你手頭的數(shù)據(jù)是百萬級以上的大場景、密度忽高忽低、或者噪聲明顯又不想在法向量這一步花掉太多時(shí)間那這篇文章應(yīng)該能給你一個(gè)非常可落地的替代方案。不管你是做激光雷達(dá)點(diǎn)云、三維重建還是機(jī)械視覺的定位抓取這套思路都通用。1. 常規(guī)法向量估計(jì)為什么在大場景下容易“翻車”1.1 K近鄰與半徑搜索各自的“局部性”悖論pcl::NormalEstimation最常用的方式有兩種K近鄰搜索和半徑搜索。K近鄰的邏輯很簡單以每個(gè)點(diǎn)為中心找最近的 K 個(gè)點(diǎn)對這 K 個(gè)點(diǎn)做 PCA。聽著很合理但實(shí)際用起來有兩個(gè)隱藏問題。第一K 是固定數(shù)量但點(diǎn)云密度是變化的。在密集區(qū)域K 個(gè)點(diǎn)可能只覆蓋了一小塊表面法向量會(huì)被表面上的微小起伏帶偏在稀疏區(qū)域K 個(gè)點(diǎn)可能跨越了好幾個(gè)結(jié)構(gòu)邊緣比如墻角、桌面和椅子扶手混在一起法向量直接被“平均”成一個(gè)四不像的方向。第二每個(gè)點(diǎn)都要在 KD-Tree 里獨(dú)立做一次近鄰搜索。點(diǎn)數(shù)少的時(shí)候還好一旦到了百萬甚至千萬量級單次搜索的 logN 開銷累加起來非常可觀而且 KD-Tree 索引本身還要吃掉大量內(nèi)存。我見過不少項(xiàng)目的法向量計(jì)算直接成了整個(gè)處理管線的瓶頸。半徑搜索setRadiusSearch其實(shí)是在用“固定的空間尺度”換“鄰居數(shù)量可變”比 K 近鄰更貼近物理直覺。但它在密度稀疏的區(qū)域經(jīng)常出問題——鄰居數(shù)量不滿 3 個(gè)PCA 根本沒法做最后返回一堆 NaN即便湊夠了點(diǎn)數(shù)因?yàn)辄c(diǎn)分布太稀疏、跨度過大法向量也很不穩(wěn)定。這等于剛從“密度不均”的坑里跳出來又掉進(jìn)“局部空洞”的坑里。1.2 體素思路先給空間畫格子再讓點(diǎn)“對號入座”把這兩種常規(guī)思路放在一起看你會(huì)發(fā)現(xiàn)它們本質(zhì)上都在做同一件事按某種規(guī)則找鄰居。問題不在于“找鄰居”這個(gè)動(dòng)作而在于找鄰居的規(guī)則太受密度影響。體素法向量換了一個(gè)切入點(diǎn)不管點(diǎn)在哪我先用固定邊長的立方體把整個(gè)場景空間切開然后按每個(gè)點(diǎn)的坐標(biāo)把它分配進(jìn)屬于自己的那個(gè)格子?!跋犬嫺褡釉俜贮c(diǎn)”有幾個(gè)天然優(yōu)勢。第一點(diǎn)云的鄰居關(guān)系變成了格子之間的空間鄰接關(guān)系理解和操作都更直觀。第二每個(gè)格子的空間尺度是固定的無論點(diǎn)云局部密度怎么變參與計(jì)算的區(qū)域都保持一致。第三格子本質(zhì)是一個(gè)小型空間濾波器體素內(nèi)所有點(diǎn)一起參與計(jì)算能明顯壓制單點(diǎn)噪聲對法向量方向的干擾。這里得特別強(qiáng)調(diào)一下體素法和 PCL 里的VoxelGrid下采樣是兩回事。VoxelGrid是每個(gè)體素保留一個(gè)重心點(diǎn)或中心點(diǎn)目的是減少點(diǎn)數(shù)而體素法向量是讓每個(gè)體素內(nèi)的所有點(diǎn)共同參與 PCA目的是獲得更穩(wěn)的法向量。兩者可以配合使用但別混為一談。2. 體素法向量的核心原理從“點(diǎn)找鄰居”到“格子里求朝向”2.1 體素劃分的本質(zhì)一套虛擬整數(shù)坐標(biāo)索引點(diǎn)云本身是離散且無序的體素化本質(zhì)上就是給點(diǎn)云建立一套虛擬的整數(shù)坐標(biāo)索引。對任意一個(gè)點(diǎn) p先算它相對包圍盒最小點(diǎn)(min_x, min_y, min_z)的偏移量再除以體素邊長voxel_size向下取整就得到了體素坐標(biāo)(ix, iy, iz)。ix floor((p.x - min_x) / voxel_size) iy floor((p.y - min_y) / voxel_size) iz floor((p.z - min_z) / voxel_size)有了這套坐標(biāo)你可以用哈希表、八叉樹或者三維數(shù)組來管理體素。三維數(shù)組最直觀但體素?cái)?shù)量通常達(dá)到幾十萬甚至上百萬直接開數(shù)組內(nèi)存不劃算。哈希表是按需創(chuàng)建有多少個(gè)格子就建多少個(gè)桶內(nèi)存開銷基本跟著點(diǎn)云的實(shí)際分布走大場景下更實(shí)用。八叉樹當(dāng)然也可以只是遍歷葉子節(jié)點(diǎn)的代碼會(huì)稍微繞一點(diǎn)后面我給的代碼里用的是哈希表方案。實(shí)際編碼時(shí)有一個(gè)容易被忽略的細(xì)節(jié)浮點(diǎn)數(shù)處在體素邊界上時(shí)可能因?yàn)榫仍颉疤钡礁舯诟褡?。比如某個(gè)點(diǎn)的坐標(biāo)剛好等于min_x k * voxel_size理論上它就在邊界上但取整結(jié)果受浮點(diǎn)誤差影響可能掉進(jìn)k-1格。這種“跳格子”雖然只發(fā)生在極少數(shù)邊界點(diǎn)上但后續(xù)算出來的法向量會(huì)出現(xiàn)不合理的毛刺。我的習(xí)慣是計(jì)算索引之前先給坐標(biāo)加一個(gè)極小的偏移量比如1e-6f代價(jià)幾乎為零效果卻很直接。2.2 體素內(nèi)的 PCA為什么最小特征值方向才是法向量體素收集完點(diǎn)之后法向量計(jì)算就是一個(gè)標(biāo)準(zhǔn)的 PCA 過程。先求點(diǎn)集的質(zhì)心 μ然后構(gòu)造協(xié)方差矩陣C Σ(p_i - μ)(p_i - μ)^T對 C 做特征值分解得到三個(gè)特征值和對應(yīng)的三個(gè)特征向量。這里有一個(gè)特別常見、而且很多人最開始都會(huì)搞反的點(diǎn)法向量取的是協(xié)方差矩陣最小特征值對應(yīng)的特征向量不是最大特征值。原因在于物理意義體素內(nèi)的點(diǎn)如果落在某個(gè)局部表面上點(diǎn)云在切平面內(nèi)的兩個(gè)方向上散布很大對應(yīng)較大的兩個(gè)特征值而垂直于表面的方向上點(diǎn)的變化非常小對應(yīng)最小的特征值。所以法向量本質(zhì)上是“點(diǎn)云散布最小的那個(gè)方向”。如果你還是不放心可以做個(gè)驗(yàn)證實(shí)驗(yàn)?zāi)靡黄秸膲γ纥c(diǎn)云體素化之后對任意一個(gè)格子做 PCA把三個(gè)特征向量打印出來。你會(huì)發(fā)現(xiàn)兩個(gè)大特征值對應(yīng)的向量幾乎平行于墻面而最小特征值對應(yīng)的向量垂直于墻面。這個(gè)結(jié)論一旦記住后面看代碼就不會(huì)再犯方向性錯(cuò)誤。另外還要注意協(xié)方差矩陣退化的情況。體素內(nèi)點(diǎn)數(shù)少于 3 時(shí)矩陣秩不夠求出的特征向量不穩(wěn)定即使點(diǎn)數(shù)夠如果點(diǎn)幾乎共線比如采集到一根細(xì)桿最小特征值和第二特征值會(huì)非常接近法向量方向就會(huì)高度敏感。這也是為什么“每個(gè)體素內(nèi)的最少點(diǎn)數(shù)”值得單獨(dú)拎出來當(dāng)一個(gè)重要的調(diào)參項(xiàng)。2.3 法向量的方向歧義PCA 只給方向不負(fù)責(zé)“朝向”PCA 給出的法向量只是一個(gè)帶符號的方向整體翻轉(zhuǎn) 180 度之后照樣滿足特征分解的要求。換句話說同一塊平面上的點(diǎn)有的格子算出來法向量朝“外”有的格子朝“里”這是所有基于 PCA 的法向量估計(jì)方法都繞不開的歧義問題。最常用的修正是視點(diǎn)矯正。思路很簡單法向量應(yīng)該大致指向傳感器方向。取一個(gè)視點(diǎn)坐標(biāo)通常是激光雷達(dá)或相機(jī)的位置計(jì)算視點(diǎn)到當(dāng)前點(diǎn)的方向向量如果法向量與這個(gè)方向向量的點(diǎn)積小于 0就把法向量翻轉(zhuǎn)。if (n · (viewpoint - p) 0) n -n;這個(gè)方法在單幀點(diǎn)云、視點(diǎn)明確的場景下效果很好。但如果你的點(diǎn)云是多次掃描拼接出來的不同區(qū)域其實(shí)來自不同的傳感器位置再用一個(gè)全局視點(diǎn)就不太準(zhǔn)確了。更穩(wěn)的做法是方向傳播從某個(gè)法向量可靠的點(diǎn)出發(fā)沿著鄰近體素逐漸向外擴(kuò)散凡是和當(dāng)前法向量夾角大于 90 度的鄰居就翻轉(zhuǎn)。這種方式不依賴傳感器位姿計(jì)算量稍大卻是多幀拼接場景下更好的選擇。3. 用 PCL 實(shí)現(xiàn)體素法向量從“偽體素法”到自寫 PCA 模塊3.1 先給你看一個(gè)偷懶方案VoxelGrid NormalEstimation說實(shí)話PCL 里沒有一個(gè)直接叫“Voxel-based Normal”的現(xiàn)成類。如果你想最快看到效果可以先走一套“偽體素法”先用VoxelGrid把點(diǎn)云抽稀再對剩下的點(diǎn)做NormalEstimation。很多項(xiàng)目里說的“體素法向量”其實(shí)就是這個(gè)組合。pcl::VoxelGridpcl::PointXYZ vg; vg.setInputCloud(cloud); vg.setLeafSize(0.05f, 0.05f, 0.05f); pcl::PointCloudpcl::PointXYZ::Ptr downsampled(new pcl::PointCloudpcl::PointXYZ); vg.filter(*downsampled); pcl::NormalEstimationpcl::PointXYZ, pcl::Normal ne; pcl::search::KdTreepcl::PointXYZ::Ptr tree(new pcl::search::KdTreepcl::PointXYZ); ne.setInputCloud(downsampled); ne.setSearchMethod(tree); ne.setKSearch(20); pcl::PointCloudpcl::Normal::Ptr normals(new pcl::PointCloudpcl::Normal); ne.compute(*normals);這個(gè)方案的優(yōu)勢是代碼量小PCL 版本兼容性好而且確實(shí)能大幅減少計(jì)算量。但你會(huì)發(fā)現(xiàn)一個(gè)問題VoxelGrid把每個(gè)體素壓縮成重心點(diǎn)之后NormalEstimation又回到了“K 近鄰找鄰居”的老路只是點(diǎn)少了、算得快了。它并不等于“每個(gè)體素內(nèi)的點(diǎn)共同參與 PCA”。所以我堅(jiān)持把這套方案叫“偽體素法”適合點(diǎn)云太大、需要先降采樣、對法向量精度要求不那么極端的任務(wù)。3.2 更貼合概念的方案哈希體素 自寫 PCA既然 PCL 沒有現(xiàn)成類那就自己寫一個(gè)。下面這段代碼是完整可編譯的思路分三步第一遍歷點(diǎn)云把每個(gè)點(diǎn)按坐標(biāo)分進(jìn)哈希表管理的體素第二對每個(gè)體素內(nèi)的點(diǎn)做 PCA第三做視點(diǎn)方向矯正給每個(gè)點(diǎn)填上法向量。#include pcl/point_cloud.h #include pcl/point_types.h #include pcl/common/common.h #include Eigen/Eigenvalues #include unordered_map #include tuple #include vector #include limits #include cmath using PointT pcl::PointXYZ; // 體素坐標(biāo)哈希三個(gè)素?cái)?shù)組合碰撞由 unordered_map 的鍵比較兜底 struct VoxelHash { size_t operator()(const std::tupleint, int, int v) const { size_t ix static_castsize_t(std::get0(v)); size_t iy static_castsize_t(std::get1(v)); size_t iz static_castsize_t(std::get2(v)); return (ix * 73856093u) ^ (iy * 19349669u) ^ (iz * 83492791u); } }; Eigen::Vector3f EstimateVoxelNormal(const std::vectorEigen::Vector3f pts) { if (pts.size() 3) return Eigen::Vector3f::Zero(); Eigen::Vector3f centroid Eigen::Vector3f::Zero(); for (const auto p : pts) centroid p; centroid / static_castfloat(pts.size()); Eigen::Matrix3f cov Eigen::Matrix3f::Zero(); for (const auto p : pts) { Eigen::Vector3f diff p - centroid; cov diff * diff.transpose(); } Eigen::SelfAdjointEigenSolverEigen::Matrix3f solver(cov); Eigen::Vector3f n solver.eigenvectors().col(0); if (n.norm() 1e-12f) return Eigen::Vector3f::Zero(); return n.normalized(); } void ComputeVoxelNormal(pcl::PointCloudPointT::Ptr cloud, float voxel_size, size_t min_pts, const Eigen::Vector3f viewpoint, pcl::PointCloudpcl::Normal::Ptr normals) { Eigen::Vector4f min_pt, max_pt; pcl::getMinMax3D(*cloud, min_pt, max_pt); std::unordered_mapstd::tupleint, int, int, std::vectorsize_t, VoxelHash voxel_map; for (size_t i 0; i cloud-size(); i) { const PointT p cloud-at(i); int ix static_castint(std::floor((p.x - min_pt[0]) / voxel_size)); int iy static_castint(std::floor((p.y - min_pt[1]) / voxel_size)); int iz static_castint(std::floor((p.z - min_pt[2]) / voxel_size)); voxel_map[{ix, iy, iz}].push_back(i); } normals-resize(cloud-size()); for (auto n : *normals) { n.normal_x n.normal_y n.normal_z std::numeric_limitsfloat::quiet_NaN(); } for (const auto kv : voxel_map) { const std::vectorsize_t indices kv.second; if (indices.size() min_pts) continue; std::vectorEigen::Vector3f pts; pts.reserve(indices.size()); for (size_t idx : indices) { const PointT p cloud-at(idx); pts.emplace_back(p.x, p.y, p.z); } Eigen::Vector3f n EstimateVoxelNormal(pts); if (n.squaredNorm() 0.5f) continue; for (size_t idx : indices) { const PointT p cloud-at(idx); Eigen::Vector3f to_view viewpoint - Eigen::Vector3f(p.x, p.y, p.z); Eigen::Vector3f oriented (n.dot(to_view) 0.0f) ? -n : n; pcl::Normal out normals-at(idx); out.normal_x oriented.x(); out.normal_y oriented.y(); out.normal_z oriented.z(); out.curvature 0.0f; } } }3.3 關(guān)鍵代碼拆解三處容易寫錯(cuò)的地方第一處是體素坐標(biāo)計(jì)算。這里必須用std::floor而不是直接int強(qiáng)轉(zhuǎn)。坐標(biāo)為正數(shù)時(shí)直接強(qiáng)轉(zhuǎn)等價(jià)于向下取整但一旦浮點(diǎn)誤差導(dǎo)致偏移量微負(fù)強(qiáng)轉(zhuǎn)會(huì)得到一個(gè)錯(cuò)誤的體素索引floor能保證任何情況下取整都對得上。第二處是剛才反復(fù)強(qiáng)調(diào)的col(0)。Eigen::SelfAdjointEigenSolver返回的特征向量按特征值升序排列所以col(0)就是最小特征值對應(yīng)的特征向量。如果誤取col(2)你得到的法向量其實(shí)是平行于表面的方向等于整體轉(zhuǎn)了個(gè) 90 度。這個(gè)錯(cuò)誤我見過不止一次。第三處是輸出法向量的初始化。resize之后pcl::Normal里的值是不確定的如果某些體素因?yàn)辄c(diǎn)數(shù)不足被跳過這些點(diǎn)的法向量就會(huì)是未初始化垃圾值比 NaN 更坑。所以代碼里先把整個(gè)輸出設(shè)為 NaN后續(xù)只有成功計(jì)算的點(diǎn)才會(huì)被覆蓋。這樣在可視化或下游處理時(shí)你一眼就能認(rèn)出哪些點(diǎn)的法向量不可靠。另外這套代碼給同一個(gè)體素里的所有點(diǎn)填了同樣的法向量。這是體素法的特性它本身輸出的是“體素級”法向量平滑度很好但空間分辨率有限。如果你下游需要非常細(xì)膩的逐點(diǎn)法向量變化后面可以把體素尺寸縮小或者改用我下一節(jié)要說的移動(dòng)窗口思路。4. 參數(shù)怎么調(diào)、效果怎么驗(yàn)一組實(shí)測數(shù)據(jù)4.1 體素尺寸先估算平均點(diǎn)間距再做選擇體素邊長是整個(gè)體素法向量的靈魂參數(shù)。設(shè)太大一個(gè)格子里混進(jìn)多個(gè)不同平面法向量被“平均”得面目全非設(shè)太小格子里的點(diǎn)數(shù)太少PCA 算不準(zhǔn)也給不出穩(wěn)定的法向量。比較穩(wěn)的做法是先估算一下點(diǎn)云的平均點(diǎn)間距。用包圍盒體積除以點(diǎn)數(shù)的三次方根得到一個(gè)大致的間距參考值然后取體素邊長等于這個(gè)間距的 2 到 5 倍。比如一份包圍盒體積約 125 立方米、點(diǎn)數(shù)約 300 萬的點(diǎn)云平均點(diǎn)間距大概是cbrt(125 / 3000000)約等于 0.035 米取 4 倍的話體素邊長約 0.14 米。這個(gè)值在多數(shù)室內(nèi)場景下都能得到不錯(cuò)的效果。float volume (max_pt[0] - min_pt[0]) * (max_pt[1] - min_pt[1]) * (max_pt[2] - min_pt[2]); float avg_spacing std::cbrt(volume / cloud-size()); float voxel_size avg_spacing * 4.0f;如果你的點(diǎn)云是從網(wǎng)格模型上均勻采樣來的密度規(guī)則均勻體素邊長可以回到 2 倍平均點(diǎn)間距附近以保留更多邊緣細(xì)節(jié)。如果是機(jī)載或車載激光掃描數(shù)據(jù)密度很不均勻體素邊長取到 5 倍甚至更大都很常見因?yàn)樗粗卣w平滑度而不是局部細(xì)節(jié)。4.2 最小點(diǎn)數(shù)閾值“夠算一次 PCA”只是底線我一開始習(xí)慣把min_pts設(shè)成 3想著三個(gè)點(diǎn)就能確定一個(gè)平面。實(shí)際跑下來大量體素的法向量在不同幀的點(diǎn)云之間跳來跳去尤其是有輕微噪聲的數(shù)據(jù)三四個(gè)點(diǎn)的 PCA 對噪聲極度敏感。后來我把閾值提到 8 到 12效果穩(wěn)定了很多。原理不復(fù)雜PCA 依賴點(diǎn)云在空間中的散布統(tǒng)計(jì)樣本太少時(shí)統(tǒng)計(jì)量本身的方差就很大算出來的方向自然不穩(wěn)。閾值也不是越大越好。設(shè)太大那些點(diǎn)數(shù)較少的稀疏體素會(huì)被跳過法向量出現(xiàn)大片空洞。我常用的策略是min_pts取 8 作為底線同時(shí)配合一個(gè)體素合并的補(bǔ)救動(dòng)作如果某個(gè)體素點(diǎn)數(shù)不足就把它周圍3x3x3鄰域里的點(diǎn)全部合并進(jìn)來一起算 PCA。這樣既不浪費(fèi)稀疏區(qū)域的點(diǎn)又能保證參與計(jì)算的點(diǎn)數(shù)足夠。4.3 一組消費(fèi)級機(jī)器上的對比數(shù)據(jù)這里放一組我自己跑過的對比結(jié)果。數(shù)據(jù)來自一份約 300 萬點(diǎn)的室內(nèi)走廊點(diǎn)云機(jī)器是普通的移動(dòng)工作站只統(tǒng)計(jì)法向量估計(jì)這一段的耗時(shí)不含點(diǎn)云讀寫時(shí)間。不同機(jī)器數(shù)值肯定不一樣但橫向?qū)Ρ鹊内厔菔欠€(wěn)定的。方案關(guān)鍵參數(shù)耗時(shí)索引/輔助內(nèi)存法向量表現(xiàn)常規(guī)K近鄰K20約2.2秒KD-Tree索引約120MB邊緣毛刺稀疏區(qū)抖動(dòng)半徑搜索r0.08m約3.6秒KD-Tree索引約120MB均勻但稀疏區(qū)出現(xiàn)不少NaN體素法(固定網(wǎng)格)v0.05m, min_pts8約0.8秒哈希表約10MB平滑穩(wěn)定邊緣略鈍體素法(移動(dòng)窗口)v0.05m, min_pts8約1.7秒哈希表約10MB穩(wěn)定邊緣保留更好從數(shù)據(jù)里能很直觀地看出來體素法在耗時(shí)上的優(yōu)勢非常明顯。原因在于它只用一次遍歷就把點(diǎn)分配進(jìn)體素之后是逐體素批量計(jì)算省掉了每個(gè)點(diǎn)單獨(dú)做 KD-Tree 搜索的開銷。代價(jià)也很明顯固定網(wǎng)格法在物體邊緣處法向量偏鈍因?yàn)橐粋€(gè)體素可能同時(shí)“裝”了墻面和門框上的點(diǎn)PCA 結(jié)果被平均掉了。如果你需要兼顧邊緣細(xì)節(jié)就得上移動(dòng)窗口體素這個(gè)下面展開說。5. 實(shí)操中繞不開的坑以及我的解決習(xí)慣5.1 “體素抖動(dòng)”邊界上的點(diǎn)為什么會(huì)突然變方向固定網(wǎng)格法有一個(gè)常被忽略的問題一個(gè)點(diǎn)只要稍微移動(dòng)一點(diǎn)就可能從格子的這一側(cè)掉到另一側(cè)導(dǎo)致它參與計(jì)算的法向量發(fā)生跳變。在單幀靜態(tài)點(diǎn)云上你可能看不出影響但如果后面拿同一場景的多幀點(diǎn)云做配準(zhǔn)或形變分析這種“體素抖動(dòng)”會(huì)給結(jié)果引入額外的高頻噪聲。我的解法是移動(dòng)窗口體素。對每個(gè)查詢點(diǎn)不預(yù)先固定網(wǎng)格而是以該點(diǎn)自身為中心取一個(gè)邊長約等于體素尺寸的立方體收集落在立方體內(nèi)的點(diǎn)做 PCA。移動(dòng)窗口天然解決了邊界跳變問題因?yàn)槟阌肋h(yuǎn)以當(dāng)前點(diǎn)為中心。代價(jià)是每個(gè)點(diǎn)都要重新做一次鄰域搜索耗時(shí)比固定網(wǎng)格高一截也就是表格里那個(gè) 1.7 秒的由來。如果不想犧牲太多性能還有一個(gè)折中的土辦法把固定網(wǎng)格法做兩次第二次把所有點(diǎn)坐標(biāo)整體平移半個(gè)體素邊長分別計(jì)算結(jié)果后對同一個(gè)點(diǎn)取兩組法向量的平均方向。這能明顯減少因體素邊界導(dǎo)致的跳變計(jì)算量只是翻倍在很多場景下很劃算。5.2 稀疏區(qū)域的大片 NaN 和“幽靈法向量”體素法出現(xiàn) NaN絕大多數(shù)情況是格子里點(diǎn)數(shù)不足被跳過。還有一類情況更迷惑點(diǎn)數(shù)夠但點(diǎn)都分布在一個(gè)幾乎共線的細(xì)長結(jié)構(gòu)上PCA 給出的法向量方向不穩(wěn)定視覺上就像有一個(gè)“幽靈法向量”在點(diǎn)云里到處亂指。對于點(diǎn)數(shù)不足除了前面說的3x3x3鄰域點(diǎn)合并還可以把無效體素直接標(biāo)記出來交給后續(xù)處理流程判斷是否丟棄。對于細(xì)長結(jié)構(gòu)光靠體素法本身很難根治因?yàn)樗举|(zhì)上就是在估計(jì)局部主方向而細(xì)長結(jié)構(gòu)本來就沒有明確的法向。我的處理習(xí)慣是做法向量之前先做一次平滑比如用 PCL 的 MLS 投影或者簡單的雙邊濾波把細(xì)桿上的點(diǎn)稍微“攤”開一點(diǎn)法向量就會(huì)穩(wěn)定很多。注意平滑強(qiáng)度別設(shè)太大不然桌腿會(huì)被抹成一片糊。5.3 多幀拼接場景的方向一致性別迷信單一視點(diǎn)前面講了視點(diǎn)矯正但在多幀拼接場景里它有一個(gè)天生的盲區(qū)拼接后的點(diǎn)云中不同區(qū)域真正對應(yīng)的“視線方向”其實(shí)是當(dāng)初各幀傳感器各自的位置而不是某個(gè)固定的全局點(diǎn)。用全局視點(diǎn)矯正會(huì)把很多本來朝外的法向量翻成朝里后續(xù)做曲面重建時(shí)出現(xiàn)大面積空洞和反向面片。我在多幀場景里更常用方向傳播。先選一個(gè)法向量穩(wěn)定可靠的體素作為種子然后用 BFS/DFS 在體素的鄰接關(guān)系上傳播。每次訪問相鄰體素時(shí)如果兩者的法向量夾角超過 90 度就翻轉(zhuǎn)相鄰體素的法向量。整個(gè)過程不依賴任何傳感器位姿適應(yīng)性強(qiáng)得多。實(shí)現(xiàn)時(shí)只需要一個(gè)隊(duì)列和一張?jiān)L問標(biāo)記數(shù)組代碼大約三四十行值得放進(jìn)自己的點(diǎn)云處理工具庫里。最后分享一個(gè)我自己踩過之后長期在用的經(jīng)驗(yàn)體素法向量用來做前置平滑非常穩(wěn)但如果你下游要提取非常精細(xì)的邊緣特征別把體素設(shè)太大也別完全拋棄半徑搜索。更通用的做法是先用小體素的體素法快速拿到主體法向再用半徑搜索在小尺度上修正邊緣部分的法向量。這樣既保住了速度也保住了細(xì)節(jié)。說到底法向量估計(jì)的選型沒有絕對最優(yōu)只有和你的數(shù)據(jù)、下游任務(wù)匹配不匹配。