cartographer 源码解析 (五)
相關鏈接:
cartographer 源碼解析(一)
cartographer 源碼解析(二)
cartographer 源碼解析(三)
cartographer 源碼解析(四)
cartographer 源碼解析(五)
cartographer 源碼解析(六)
本章節主要講的內容是激光畸變的來源和矯正,即對外推器的數據進行激光矯正。主要還是數據預處理之激光匹配的問題。但是這些預處理的東西都非常關鍵,這些數據的外推器,對激光進行畸變矯正。
其實激光矯正的原理是很簡單,只要你想通了,就很簡單。然后就是激光如何進行矯正。
怎么進行配準呢,首先找到一段激光A,一段激光B, 然后找到他們的R和T然后放到一塊去。重復的地方放到一起,不重復的地方就是地圖的擴展,激光的地圖匹配都是以幀為單位。
首先說一下畸變的來源,由于激光雷達是運動的,但是我們收到的點都假設起始點是跟第一個點發出的時候的雷達的位置是一樣的,所有就出現了畸變。
綠色的是墻,圓圈是雷達,自上往下分別是雷達的運動,紫色的是雷達發出的激光的位置。
從圖中,我們可以看到是未矯正的激光最后呈現出的樣子
最終呈現出的效果如下圖所示
這是矯正之前的圖,可以發現激光雷達呈現出的墻已經斜了。
而我們根據雷達起始點的位置。對激光進行矯正,獲得效果會是什么樣子呢?
先看看矯正前的激光點連在一起形成的墻吧。黃色的就是墻。
而矯正之后的藍色部分是我們的墻
從這里可以意識到矯正的重要性。
直接開始了。
上回書說道。2D激光的局部軌跡構建器添加測距數據。什么?忘了啊,那直接上代碼吧。
local_trajectory_builder_2d.cc
我們往局部軌跡構建器中添加測距數據,如果是激光數據的話就是激光測距數據。進來之后首先對激光數據進行同步,代碼如下:
auto synchronized_data =range_data_collator_.AddRangeData(sensor_id, unsynchronized_data);那么什么情況下才會對激光數據進行同步呢?主要有一下情況:
- 情況1: 你有兩個激光雷達,兩個激光雷達的名字重復了,比如雷達1 叫scan1 雷達2也叫scan1.但是兩幀激光雷達的數據有重疊的部分。如下圖所示
其中橙色的是雷達1 的一幀激光數據,紅色是雷達2的一幀激光數據,藍色是兩幀激光雷達數據重復的部分。
看上面的代碼我們知道,我們有一個range_data_collator_, 之前我們有一個collator_trajectory_builder 里面有一個collator, 我們的局部軌跡構建器中也有個collator。他們共同作用就是收集加同步的作用。這個range_data_collator_收集齊了所有的測距數據,比如scan1、scan2、scan3然后再對這些數據進行同步,我們會在這個基礎上,找到時間重復的,把重復的那一部分去掉。只保留不重復的部分。 - 情況2: 還有一種情況就是有多個雷達,他們分別是scan1、scan2、scan3。當這些scan具有相同部分的時候,不是去掉時間同步的部分,而是將重復部分的點以時間的順序重新排序,排序后生成一個新的scan,我們暫時起名叫做scan_merge吧。
其中紅色是scan1、紫色是scan2、最下面是scan1和scan2結合后的一整幀點云數據。中間重復部分以時間順序排列好,點云會特別的密。
好了,我們正式開始,雖然還沒有涉及到scan-match,也就是激光匹配。不過我們還是數據預處理的模塊。主要就是外推器對激光數據的畸變矯正處理。
激光如何配準呢?
- 首先要有一幀激光A
- 然后要有一幀激光B
- 接著找到激光A和激光B相同的部分,把相同的部分拼接到一起。首先要算出R和T,不重復的部分相當于激光的擴展了。
以上有一個細節就是進行激光配準的時候都是以幀為單位的。
比如說一幀激光雷達有32768個點,為什么有這么多個點呢,那是因為激光雷達的開發人員一個點一個點傳比較麻煩,發的數據一定要有開頭和結尾。進行驗證校驗。如果都對了,證明數據是傳輸中沒有錯誤。所以傳輸數據是比較麻煩的。其實激光沒有一幀一幀的說法,只不過是激光轉了一圈,人為的發送給你。最合理處理激光的方法是一個點一個點的去處理,而不是一幀一幀的去處理,如果你覺得數據比較少,想cartographer,他會收集到3-4幀數據結合起來去處理。
我們從頭到尾的講一下這個代碼來捋一捋。
首先是對激光雷達的數據做一個同步
local_trajectory_builder_2d.cc
下面是得到當前激光的時間戳
const common::Time& time = synchronized_data.time;如果不使用imu,我們就嘗試自己初始化一個外推器
if (!options_.use_imu_data()) {InitializeExtrapolator(time);}然后我們再看看這個初始化外推器是如何實現的呢?
void LocalTrajectoryBuilder2D::InitializeExtrapolator(const common::Time time) {if (extrapolator_ != nullptr) {return;}CHECK(!options_.pose_extrapolator_options().use_imu_based());// TODO(gaschler): Consider using InitializeWithImu as 3D does.extrapolator_ = absl::make_unique<PoseExtrapolator>(::cartographer::common::FromSeconds(options_.pose_extrapolator_options().constant_velocity().pose_queue_duration()),options_.pose_extrapolator_options()2015-03-08.constant_velocity().imu_gravity_time_constant());extrapolator_->AddPose(time, transform::Rigid3d::Identity()); }這個外推器的代碼我們后面就進行詳細的講解。
如果我們使用imu數據的話,我們會在添加imu數據的時候添加外推器。
void LocalTrajectoryBuilder2D::AddImuData(const sensor::ImuData& imu_data) {CHECK(options_.use_imu_data()) << "An unexpected IMU packet was added.";InitializeExtrapolator(imu_data.time);extrapolator_->AddImuData(imu_data); }所謂的添加imu數據,實際上就是把imu數據給到外推器里面。
這一步InitializeExtrapolator(imu_data.time);生成imu數據,直接送到這一步的extrapolator_->AddImuData(imu_data);的外推器里面。
岔開話題了哈,我們接著剛才的沒有使用imu數據,自己創建外推器的代碼開始講。
if (extrapolator_ == nullptr) {// Until we've initialized the extrapolator with our first IMU message, we// cannot compute the orientation of the rangefinder.LOG(INFO) << "Extrapolator not yet initialized.";return nullptr;}如果外推器是空指針,那么我們就發出警告,并返回空。
然后做一個檢查,看看同步之后的數據是否是空的
CHECK(!synchronized_data.ranges.empty());然后再檢查同步之后的激光的時間戳,是否是小于等于0的
CHECK_LE(synchronized_data.ranges.back().point_time.time, 0.f);下面又是一個公式,就是第一個點的絕對時間戳等于相對時間戳+第一個點的絕對時間戳
const common::Time time_first_point =time +common::FromSeconds(synchronized_data.ranges.front().point_time.time);然后再接著往下看:
if (time_first_point < extrapolator_->GetLastPoseTime()) {LOG(INFO) << "Extrapolator is still initializing.";return nullptr;}從條件語句可以知道,如果滿足當前第一個點的時間戳比外推器最后一個(最新)位姿的時間戳要早。我們就不處理了,直接return了。然后接著往下看。我們每個點的發射位置都是變化的,每個點都要優化估計發射的位姿。
local_trajectory_builder_2d.cc
我們逐行的看
std::vector<transform::Rigid3f> range_data_poses;首先這一行就是定義一個容器存放每個點被發射的時候的雷達的位姿。
range_data_poses.reserve(synchronized_data.ranges.size());然后是預留多少空間,開辟多大的存儲呢?當然是有多少個點,就預留多少的空間了。多少個點呢?------synchronized_data.ranges.size()
接下來不用說,我們猜也能猜到,肯定是存儲每個點的發射位置。我們繼續往下看吧。
繼續逐行解析,
bool warned = false;首先是設置一個布爾值類型的判斷,用來負責斷定是否要發出警告。
接著是一個for循環,從循環的執行條件可以看出是遍歷所有的點。
for (const auto& range : synchronized_data.ranges) {...... }再講下一行代碼
common::Time time_point = time + common::FromSeconds(range.point_time.time);這個time_point是什么呢?他是當前一幀點云的時間戳time + 當前點的相對時間戳,結果就等于當前點的絕對時間戳。
再講一下行代碼的時候,我們要清楚一個概念,首先是前面講了一個extrapolator_->GetLastPoseTime(),這一段有提到了一個extrapolator_->GetLastExtrapolatedTime()。雖然都是獲取時間,但是他們的概念是不一樣的,首先是第一個是最近的位姿的時間,然后這個的是上一次外推的時間。
if (time_point < extrapolator_->GetLastExtrapolatedTime()) {if (!warned) {LOG(ERROR)<< "Timestamp of individual range data point jumps backwards from "<< extrapolator_->GetLastExtrapolatedTime() << " to " << time_point;warned = true;}time_point = extrapolator_->GetLastExtrapolatedTime();}跟上一次外推時間作對比,發現當前點的時間比上一次外推時間小,說明比外推時間早,這不太對,說明這一堆點排序不對,沒有排好順序。并發出警告。你上一次的點的時間戳獲取到了,得到一個外推時間,下一個點比上一個點的時間還要早,這樣你就不是預測發射點的位姿,而是回退驗證之前點的位姿了。這說明這次操作是異常的,那么我們怎么做呢?直接把time_point獲取上一次外推器的時間。
time_point = extrapolator_->GetLastExtrapolatedTime();這時間就算不增長,也不能外推一個比之前外推時間要早的時間戳。干脆還有上一次的外推時間戳。
緊接著,用外推器外推一個pose,然后添加到位姿容器中。
range_data_poses.push_back(extrapolator_->ExtrapolatePose(time_point).cast<float>());我們先不看外推器外推的原理,我們先把這個邏輯走完。
我們接著往下看
這個num_accumulated_是什么呢?我們cartographer支持好幾幀連在一起去處理的。每一幀激光來的時候num_accumulated_都會增加一下,同時如果是第一幀激光來的話。accumulated_range_data_會初始化一下,初始化成RangeData這個類型。然后等到指定的幾幀激光拼成一個大激光之后。num_accumulated_就又會變成0。
接下來重點來了,是激光矯正的核心,那么怎么突出這個重點呢?我要開始變色了,變色之后,你就知道這個里特別重點了。
變色!
變紅色!
變綠色!
變黃色!
變紫色!
變橙色!
變黑色!
變粉色!
變藍色!
好了,相信你已經意識到問題的嚴重性,不對,說錯了,應該是下面代碼的重要性了,那么我要開始我的表演了!
啥也不說了,翠花。上酸菜。
// Drop any returns below the minimum range and convert returns beyond the// maximum range into misses.for (size_t i = 0; i < synchronized_data.ranges.size(); ++i) {const sensor::TimedRangefinderPoint& hit =synchronized_data.ranges[i].point_time;const Eigen::Vector3f origin_in_local =range_data_poses[i] *synchronized_data.origins.at(synchronized_data.ranges[i].origin_index);sensor::RangefinderPoint hit_in_local =range_data_poses[i] * sensor::ToRangefinderPoint(hit);const Eigen::Vector3f delta = hit_in_local.position - origin_in_local;const float range = delta.norm();if (range >= options_.min_range()) {if (range <= options_.max_range()) {accumulated_range_data_.returns.push_back(hit_in_local);} else {hit_in_local.position =origin_in_local +options_.missing_data_ray_length() / range * delta;accumulated_range_data_.misses.push_back(hit_in_local);}}}代碼上完了,是不是有點上頭,我們一點點的說,先從for循環開始吧。我們回溯一下,弄明白這到底是怎么一回事。
for (size_t i = 0; i < synchronized_data.ranges.size(); ++i) {......}首先有個synchronized_data,這同步的數據是怎么從ros的LaserScan變成的同步數據的呢?
首先我們要看一下synchronized_data是什么數據類型,先找到他的定義。
local_trajectory_builder_2d.cc
這里定義的是auto,那么緊接著我們看看函數返回的類型就知道它是定義的什么類型了。
range_data_collator.cc
找到函數定義的位置,發現synchronized_data的數據類型是sensor::TimedPointCloudOriginData。
然后再看看TimedPointCloudOriginData。
其中包含了一個RangeMeasurement的結構體,然后是時間time,origins是雷達位置的可變數組,ranges是相關的點的可變數組。
timed_point_cloud_data.h
首先是TimedRangefinderPoint這個數據類型。具體是什么呢?我們看看
struct TimedRangefinderPoint {Eigen::Vector3f position;float time; };我們一目了然,就是帶有時間戳的三維點。
這樣我們再回溯一下:RangeMeasurement其實是一個帶有時間戳的三維點point_time以及信號強度intensity,再加上激光雷達的發射激光的位置的索引origin_index。
然后我們再看const sensor::TimedPointCloudData& unsynchronized_data這個沒有同步的數據是什么格式的??梢灾罃祿愋褪荰imedPointCloudData。具體里面都有什么呢?
timed_point_cloud_data.h
然后我們就可以知道,上面的數據類型是存放一個激光源發出的一幀激光,而之前的TimedPointCloudOriginData存放的是幾幀激光合在一起形成的激光。這就是AddRangeData接收到的unsynchronized_data數據。那這個AddRangeData是哪里調用的呢?
global_trajectory_builder.cc
這個AddSensorData是怎么傳到這里面的呢?我們之前也分析過。是警告過collatored_trajectory_builder里面,其中包括利用collator進行多個不同傳感器之間的數據進行同步。然后逐個分發。這個中間過程是不會有數據進行轉變的。
離這個最近的轉變是什么,那不就是cartographer_ros里面。把ros格式的msg轉化成cartographer需要的點云格式,說到這里你肯定是忘了,所有我們回憶一下吧。
sensor_bridge.cc
首先,它接收到了一個ros格式的msg,然后它將msg轉化成了PointCloudWithIntensities格式消息,然后我們再去看看PointCloudWithIntensities具體的數據結構是什么類型的。
struct PointCloudWithIntensities {TimedPointCloud points;std::vector<float> intensities; };進一步的再看看TimedPointCloud數據類型。
using TimedPointCloud = std::vector<TimedRangefinderPoint>;發現它是一個TimedRangefinderPoint類型的數組,但具體我們看看這個數組的數據類型是什么呢?
struct TimedRangefinderPoint {Eigen::Vector3f position;float time; };我們發現是帶時間戳的三維點的數據結構。
也就是說TimedPointCloud,內容如其名字,也就存儲一系列帶時間戳的點的數組。PointCloudWithIntensities是在此基礎上的再加上點云的強度信息。格式轉換成功了,我們再將數據傳給HandleLaserScan
sensor_bridge.cc
void SensorBridge::HandleLaserScan(const std::string& sensor_id, const carto::common::Time time,const std::string& frame_id,const carto::sensor::PointCloudWithIntensities& points) {if (points.points.empty()) {return;}CHECK_LE(points.points.back().time, 0.f);// TODO(gaschler): Use per-point time instead of subdivisions.for (int i = 0; i != num_subdivisions_per_laser_scan_; ++i) {const size_t start_index =points.points.size() * i / num_subdivisions_per_laser_scan_;const size_t end_index =points.points.size() * (i + 1) / num_subdivisions_per_laser_scan_;carto::sensor::TimedPointCloud subdivision(points.points.begin() + start_index, points.points.begin() + end_index);if (start_index == end_index) {continue;}const double time_to_subdivision_end = subdivision.back().time;// `subdivision_time` is the end of the measurement so sensor::Collator will// send all other sensor data first.const carto::common::Time subdivision_time =time + carto::common::FromSeconds(time_to_subdivision_end);auto it = sensor_to_previous_subdivision_time_.find(sensor_id);if (it != sensor_to_previous_subdivision_time_.end() &&it->second >= subdivision_time) {LOG(WARNING) << "Ignored subdivision of a LaserScan message from sensor "<< sensor_id << " because previous subdivision time "<< it->second << " is not before current subdivision time "<< subdivision_time;continue;}sensor_to_previous_subdivision_time_[sensor_id] = subdivision_time;for (auto& point : subdivision) {point.time -= time_to_subdivision_end;}CHECK_EQ(subdivision.back().time, 0.f);HandleRangefinder(sensor_id, subdivision_time, frame_id, subdivision);} }由于之前講過,這里就不細講了,主要是將points轉化成一個個的subdivision。
我們注意到了最下面有一個函數HandleRangefinder(sensor_id, subdivision_time, frame_id, subdivision);。在這里我們進一步的來看到底是干什么的。
sensor_bridge.cc
下面我們繼續逐行分析。
const auto sensor_to_tracking =tf_bridge_.LookupToTracking(time, CheckNoLeadingSlash(frame_id));首先我們經過tf_bridge的查找器進行查找,tf_bridge是用來進行坐標轉換的,記錄坐標系之間的轉換關系的工具,查找的是sensor_to_tracking。
我又要開始變色了。
to is in !
to is at !
舉個例子來說明情況
首先是一個激光點P
它再激光坐標系下的是這樣的
而激光坐標系在地圖坐標系下是這樣的。
怎么表示點p在map坐標系下的位置呢?
其實就是laser to map, laser is map laser at map的表示。
也就是Tlasermap?PT_{laser}^{map}*PTlasermap??P
其實就是laser 坐標系在 map坐標系下的位置。
而這個sensor_to_tracking其實就是sensor in tracking, 這個sensor在這里是雷達,其實就是激光雷達的外參。
接下來我們做什么呢?
我們經過一系列的轉換,轉換成TimedPointCloud,其實就是帶有時間戳的3D位置點的數組。
而轉化之后的格式,TimedPointCloudData是多了個origin,也即是雷達發射該點的時候的位置。
其中紅色的點為激光雷達的點,tracking_frame 坐標系是再兩輪的中心位置。前頭是激光雷達。
藍色的點P在激光雷達的坐標轉換到tracking_frame的坐標轉換關系是sensor_to_tracking。通過sensor_to_tracking我們可以將激光雷達坐標系轉換到tracking_frame坐標系中。
我們可以在代碼中看到
time作為當前一幀激光的絕對時間戳被傳送進去,然后sensor_to_tracking->translation().cast<float>()是origin,也就是激光雷達發射的原點,sensor_to_tracking,也就是激光雷達的坐標系,在track_frame里的位置。
carto::sensor::TransformTimedPointCloud(ranges, sensor_to_tracking->cast<float>())將這一堆點ranges用 sensor_to_tracking來進行轉換。也就是將這些點在雷達中的坐標轉換為在tracking_frame中的坐標。
比如我們把點P轉換到了tracking_frame中,那么為什么我們還要記錄它的發射原點。為了方便到時候計算一個某一束激光的長度,看其是否太長,或者太短??赡芫陀腥艘獑柫?#xff0c;為什么要把所有的點都記錄到tracking_frame中呢?就是因為cartographer就非常的工程,支持多激光建圖的。比如我們有多個激光雷達。最后都會集中到tracking_frame中。綜合在一起需要一個統一的坐標系,所以跟蹤什么,我們都轉換到什么上去。
那我們回到cartographer中,繼續講激光的那一段代碼中。
之前我們講畸變矯正,講到了for循環。
我們看到了hit 經過了同步之后所有點中的一幀點云synchronized_data.ranges[i],
struct TimedPointCloudOriginData {struct RangeMeasurement {TimedRangefinderPoint point_time;float intensity;size_t origin_index;};common::Time time;std::vector<Eigen::Vector3f> origins;std::vector<RangeMeasurement> ranges; };其實就是TimedPointCloud ranges;,也就是using TimedPointCloud = std::vector<TimedRangefinderPoint>;帶有時間戳的三維點的數組。
首先把ranges的point_time拿出來,也就是帶著時間戳的點的坐標拿到。
origin我們先跳過,我們先看看hit主要用來干什么。
然后我們看看ToRangefinderPoint做了什么?這個hit就是一幀激光里的相對坐標
rangefinder_point.h
這個很簡單,直接返回它的坐標。
坐標拿出來之后,又乘以range_data_poses[i],那么這個range_data_poses[i]又是什么呢?
我們之前也講過,我們給每一個點在每一個點的外推時間戳上都外推了一個pose
把每一個點的外推pose都乘以點的坐標。
我們希望做一下畸變矯正,就需要給點一個相對的坐標原點。而不是一開始上電時刻的原點。這樣才會盡量的去矯正這個畸變。主要作用畸變矯正,并把坐標都映射到了local坐標系下。
這是激光雷達的一幀點云中的點的發射的位置。在local坐標系下的表示。公式就是local 坐標系 = 點的外推位姿 X 點的坐標
我們記錄origin原點的坐標作用是什么呢?主要就是計算發射原點到被擊中的這段距離
下來是什么呢?
hit是在雷達坐標系下的被擊中的坐標,hit_in_local是local坐標系下被擊中的坐標。然后是origin是雷達坐標系下的雷達發射該點坐標,origin_in_local是激光雷達發射該點的位置。
所以hit_in_local.position - origin_in_local.norm()就是測距長度。
if (range >= options_.min_range()) {...}如果長度太小就不往里面添加了
雖然長,但有一句話我們需要記住,將來我們跟別人拉開差距的地方,其實就是細節。
if (range <= options_.max_range()) {accumulated_range_data_.returns.push_back(hit_in_local);} else {hit_in_local.position =origin_in_local +options_.missing_data_ray_length() / range * delta;accumulated_range_data_.misses.push_back(hit_in_local);}如果在有效范圍內,就添加上,如果超過了最遠測距的距離。那么也不會舍棄。
就是在此基礎上,雷達發射原點的基礎上,在這個方向上設定長度 options_.missing_data_ray_length() 。比如激光雷達照射的太遠,我們只用這一小塊。比如說你在一個空曠的地方,但是照射的點距離都賊長,那么我們就認識機器人附近是沒有東西的。
其中options_.num_accumulated_range_data()是我們設置的收集雷達的幀數,比如我們設置為2或者3,就是等到收集了2幀或者3幀數據了。
const common::Time current_sensor_time = synchronized_data.time;absl::optional<common::Duration> sensor_duration;if (last_sensor_time_.has_value()) {sensor_duration = current_sensor_time - last_sensor_time_.value();}首先計算一下,上一次傳感器傳到這里的時間,然后獲取當前的時間,二者相減獲得差值。后面會有用到。
last_sensor_time_ = current_sensor_time;num_accumulated_ = 0;更新last_sensor_time_,同時統計收集雷達幀數的變量為0,重新開始收集。
const transform::Rigid3d gravity_alignment = transform::Rigid3d::Rotation(extrapolator_->EstimateGravityOrientation(time));這個先跳過去不講,后面我們再說。
accumulated_range_data_.origin = range_data_poses.back().translation();我們用多個激光雷達建圖的時候,會有多個origin。上面代碼表示,用最后的外推位姿的發射原點作為整個一幀大激光,也即是合并之后的激光,作為它的原點。
return AddAccumulatedRangeData(time,TransformToGravityAlignedFrameAndFilter(gravity_alignment.cast<float>() * range_data_poses.back().inverse(),accumulated_range_data_),gravity_alignment, sensor_duration);最后是拿過去做激光匹配。
一幀激光里的點也會發生畸變,其原因就是發射點的位置坐標一直在變,要想糾正激光雷達的點投射的正確幾何形狀,就要求估計激光雷達發射點的位置盡可能的正確。那么這個發射點的坐標系是什么呢?是local,local在前幾章節講過,就是前端。在cartographer里面,就是前端的意思,就是激光里程計。
我們是對傳感器數據做處理,就是處理激光雷達的畸變矯正。所以我們還要恢復成一個激光雷達的坐標系里去。針對于雷達坐標系去做矯正,具體怎么做呢?我們來看看TransformToGravityAlignedFrameAndFilter這個函數吧。
local_trajectory_builder_2d.cc
TransformToGravityAlignedFrameAndFilter,這個函數具體做了什么呢?gravity_alignment是重力對齊,因為我們不用3D建圖,所以先不考慮。然后再看range_data_poses就是每一個激光點的發射原點中,而這個 range_data_poses.back().inverse()也都是相對于local坐標系下的。然后我們看這個函數具體怎么實現的,具體做了什么。
local_trajectory_builder_2d.cc
CropRangeData 比如說做重力矯正之后的,只取一定范圍內的激光。然后我們要知道transform_to_gravity_aligned_frame是最后一個激光點的位置坐標(相對于local坐標系下的)。然后呢,之前的哪些點都變成以最后一個點的坐標為原點,進行轉化。就相當于之前的點的每個發射坐標都是相對于local坐標系下的,然后一轉化,都變成基于最后那個點的坐標系為原點了,就不依靠local坐標系了。最后還有一個就是體素濾波VoxelFilter,主要是做什么的呢?就是降采樣,如果點太密了,那就降采樣。以上,就是TransformToGravityAlignedFrameAndFilter干的事情。
最后總結一下,就是做激光矯正的時候,都把每個點的位置坐標轉化到local坐標系下,轉換完了之后呢,再轉化到統一的同一個激光雷達坐標系下,變成一幀比較獨立的激光。
總結
以上是生活随笔為你收集整理的cartographer 源码解析 (五)的全部內容,希望文章能夠幫你解決所遇到的問題。
- 上一篇: html写一个猜数字游戏,JS实现网页端
- 下一篇: 【CV】Mask R-CNN:用于目标实