automobile/build-map-by-lidar-point-cloud/main.m
% https://www.mathworks.com/help/driving/ug/build-a-map-from-lidar-data-using-slam.html
% Process 3-D lidar data from a sensor mounted on a vehicle to progressively build a map and estimate the trajectory of a vehicle using simultaneous localization and mapping (SLAM).
% In addition to 3-D lidar data, an inertial navigation sensor (INS) is also used to help build the map.
% Maps built this way can facilitate path planning for vehicle navigation or can be used for localization.
% Use 3-D lidar data and IMU readings to progressively build a map of the environment traversed by a vehicle.
% While this approach builds a locally consistent map, it is suitable only for mapping small areas.
% Over longer sequences, the drift accumulates into a significant error.
% To overcome this limitation, we recognize previously visited places and tries to correct for the accumulated drift using the graph SLAM approach.
% https://www.mrt.kit.edu/z/publ/download/velodyneslam/dataset.html
baseDownloadURL = 'https://www.mrt.kit.edu/z/publ/download/velodyneslam/data/scenario1.zip';
dataFolder = fullfile(tempdir, 'kit_velodyneslam_data_scenario1', filesep);
options = weboptions('Timeout', Inf);
zipFileName = dataFolder + "scenario1.zip";
% Get the full file path to the PNG files in the scenario1 folder.
pointCloudFilePattern = fullfile(dataFolder, 'scenario1', 'scan*.png');
folderExists = exist(dataFolder, 'dir');
fileCount = 2513;
if ~folderExists
mkdir(dataFolder);
disp('Downloading ...');
websave(zipFileName, baseDownloadURL, options);
unzip(zipFileName, dataFolder);
elseif folderExists && numel(dir(pointCloudFilePattern)) < fileCount
disp('Downloading ...');
websave(zipFileName, baseDownloadURL, options);
unzip(zipFileName, dataFolder);
end
% Read data from the created folder in the form of a timetable.
% The point clouds captured by the lidar are stored in the form of PNG image files.
% Extract the list of point cloud file names in the `pointCloudTable` variable.
datasetTable = readDataset(dataFolder, pointCloudFilePattern);
pointCloudTable = datasetTable(:, 1);
insDataTable = datasetTable(:, 2:end);
% Read the first point cloud
ptCloud = readPointCloudFromFile(pointCloudTable.PointCloudFileName{1});
disp(ptCloud);
% Display the first INS reading. The `timetable` holds `Heading`, `Pitch`, `Roll`, `X`, `Y`, and `Z` information from the INS.
disp(insDataTable(1, :));
% Visualize the point clouds using `pcplayer`, a streaming point cloud display.
% The vehicle traverses a path consisting of two loops.
% In the first loop, the vehicle makes a series of turns and returns to the starting point.
% In the second loop, the vehicle makes a series of turns along another route and again returns to the starting point.
% Specify limits of the player
xlimits = [-45 45]; % meters
ylimits = [-45 45];
zlimits = [-10 20];
% Create a streaming point cloud display object
lidarPlayer = pcplayer(xlimits, ylimits, zlimits);
% Customize player axes labels
xlabel(lidarPlayer.Axes, 'X (m)');
ylabel(lidarPlayer.Axes, 'Y (m)');
zlabel(lidarPlayer.Axes, 'Z (m)');
title(lidarPlayer.Axes, 'Lidar Sensor Data');
% Skip evey other frame since this is a long sequence
skipFrames = 2;
numFrames = height(pointCloudTable);
for n = 1:skipFrames:numFrames
% Read a point cloud
fileName = pointCloudTable.PointCloudFileName{n};
ptCloud = readPointCloudFromFile(fileName);
% Visualize point cloud
view(lidarPlayer, ptCloud);
pause(0.01);
end
% Build a Map Using Odometry
% The approach consists of the following steps:
% 1) Align lidar scans: Align successive lidar scans using a point cloud registration technique.
% This uses `pcregisterndt` for registering scans.
% By successively composing these transformations, each point cloud is transformed back to the reference frame of the first point cloud.
% 2) Combine aligned scans: Generate a map by combining all the transformed point clouds.
%
% This approach of incrementally building a map and estimating the trajectory of the vehicle is called odometry.
%
% Use a `pcviewset` object to store and manage data across multiple views. A view set consists of a set of connected views.
% - Each view stores information associated with a single view.
% This information includes the absolute pose of the view, the point cloud sensor data captured at that view, and a unique identifier for the view.
% Add views to the view set using `addView`.
% - To establish a connection between views use `addConnection`.
% A connection stores information like the relative transformation between the connecting views, the uncertainty involved in computing this measurement (represented as an information matrix) and the associated view identifiers.
% - Use the `plot` method to visualize the connections established by the view set.
% These connections can be used to visualize the path traversed by the vehicle.
hide(lidarPlayer);
% Set random seed to ensure reproducibility
rng(0);
% Create an empty view set
vSet = pcviewset;
% Create a figure for view set display
hFigBefore = figure('Name', 'View Set Display');
hAxBefore = axes(hFigBefore);
% Initialize point cloud processing parameters
downsamplePercent = 0.1;
regGridSize = 3;
% Initialize transformations
absTform = rigidtform3d; % Absolute transformation to reference frame
relTform = rigidtform3d; % Relative transformation between successive scans
viewId = 1;
skipFrames = 5;
numFrames = height(pointCloudTable);
displayRate = 100; % Update display every 100 frames
for n = 1:skipFrames:numFrames
% Read point cloud
fileName = pointCloudTable.PointCloudFileName{n};
ptCloudOrig = readPointCloudFromFile(fileName);
% Process point cloud
% - Segment and remove ground plane
% - Segment and remove ego vehicle
ptCloud = processPointCloud(ptCloudOrig);
% Downsample the processed point cloud
ptCloud = pcdownsample(ptCloud, "random", downsamplePercent);
firstFrame = (n == 1);
if firstFrame
% Add first point cloud scan as a view to the view set
vSet = addView(vSet, viewId, absTform, "PointCloud", ptCloudOrig);
viewId = viewId + 1;
ptCloudPrev = ptCloud;
continue
end
% Use INS to estimate an initial transformation for registration
initTform = computeInitialEstimateFromINS(relTform, insDataTable(n - skipFrames:n, :));
% Compute rigid transformation that registers current point cloud with previous point cloud
relTform = pcregisterndt(ptCloud, ptCloudPrev, regGridSize, "InitialTransform", initTform);
% Update absolute transformation to reference frame (first point cloud)
absTform = rigidtform3d(absTform.A * relTform.A);
% Add current point cloud scan as a view to the view set
vSet = addView(vSet, viewId, absTform, "PointCloud", ptCloudOrig);
% Add a connection from the previous view to the current view, representing the relative transformation between them
vSet = addConnection(vSet, viewId - 1, viewId, relTform);
viewId = viewId + 1;
ptCloudPrev = ptCloud;
initTform = relTform;
if n > 1 && mod(n, displayRate) == 1
plot(vSet, "Parent", hAxBefore);
drawnow update;
end
end
% The view set object `vSet`, now holds views and connections.
% In the Views table of vSet, the `AbsolutePose` variable specifies the absolute pose of each view with respect to the first view.
% In the `Connections` table of `vSet`, the `RelativePose` variable specifies relative constraints between the connected views, the `InformationMatrix` variable specifies, for each edge, the uncertainty associated with a connection.
% Display the first few views and connections
head(vSet.Views);
head(vSet.Connections);
% Build a point cloud map using the created view set.
% Align the view absolute poses with the point clouds in the view set using `pcalign`.
% Specify a grid size to control the resolution of the map.
% The map is returned as a `pointCloud` object.
ptClouds = vSet.Views.PointCloud;
absPoses = vSet.Views.AbsolutePose;
mapGridSize = 0.2;
ptCloudMap = pcalign(ptClouds, absPoses, mapGridSize);
% Notice that the path traversed using this approach drifts over time.
% While the path along the first loop back to the starting point seems reasonable, the second loop drifts significantly from the starting point.
% The accumulated drift results in the second loop terminating several meters away from the starting point.
%
% A map built using odometry alone is inaccurate.
% Display the built point cloud map with the traversed path.
% Notice that the map and traversed path for the second loop are not consistent with the first loop.
hold(hAxBefore, 'on');
pcshow(ptCloudMap);
hold(hAxBefore, 'off');
close(hAxBefore.Parent);
% Correct Drift Using Pose Graph Optimization
% "Graph SLAM" is a widely used technique for resolving the drift in odometry.
% The graph SLAM approach incrementally creates a graph, where nodes correspond to vehicle poses and edges represent sensor measurements constraining the connected poses.
% Such a graph is called a "pose graph".
% The pose graph contains edges that encode contradictory information, due to noise or inaccuracies in measurement.
% The nodes in the constructed graph are then optimized to find the set of vehicle poses that optimally explain the measurements.
% This technique is called "pose graph optimization".
%
% To create a pose graph from a view set, you can use the `createPoseGraph` function.
% This function creates a node for each view, and an edge for each connection in the view set.
% To optimize the pose graph, we can use the `optimizePoseGraph` function.
%
% A key aspect contributing to the effectiveness of graph SLAM in correcting drift is the accurate detection of loops, that is, places that have been previously visited.
% This is called "loop closure detection" or "place recognition".
% Adding edges to the pose graph corresponding to loop closures provides a contradictory measurement for the connected node poses, which can be resolved during pose graph optimization.
%
% Loop closures can be detected using descriptors that characterize the local environment visible to the Lidar sensor.
% The "Scan Context" descriptor is one such descriptor that can be computed from a point cloud using the `scanContextDescriptor` function.
% This uses a `scanContextLoopDetector` object to manage the scan context descriptors that correspond to each view.
% It uses the `detectLoop` object function to detect loop closures with a two phase descriptor search algorithm.
% In the first phase, it computes the ring key subdescriptors to find potential loop candidates.
% In the second phase, it classifies views as loop closures by thresholding the scan context distance.
% Set random seed to ensure reproducibility
rng(0);
% Create an empty view set
vSet = pcviewset;
% Create a loop closure detector
loopDetector = scanContextLoopDetector;
% Create a figure for view set display
hFigBefore = figure('Name', 'View Set Display');
hAxBefore = axes(hFigBefore);
% Initialize transformations
absTform = rigidtform3d; % Absolute transformation to reference frame
relTform = rigidtform3d; % Relative transformation between successive scans
maxTolerableRMSE = 3; % Maximum allowed RMSE for a loop closure candidate to be accepted
viewId = 1;
for n = 1:skipFrames:numFrames
% Read point cloud
fileName = pointCloudTable.PointCloudFileName{n};
ptCloudOrig = readPointCloudFromFile(fileName);
% Process point cloud
% - Segment and remove ground plane
% - Segment and remove ego vehicle
ptCloud = processPointCloud(ptCloudOrig);
% Downsample the processed point cloud
ptCloud = pcdownsample(ptCloud, "random", downsamplePercent);
firstFrame = (n == 1);
if firstFrame
% Add first point cloud scan as a view to the view set
vSet = addView(vSet, viewId, absTform, "PointCloud", ptCloudOrig);
% Extract the scan context descriptor from the first point cloud
descriptor = scanContextDescriptor(ptCloudOrig);
% Add the first descriptor to the loop closure detector
addDescriptor(loopDetector, viewId, descriptor);
viewId = viewId + 1;
ptCloudPrev = ptCloud;
continue
end
% Use INS to estimate an initial transformation for registration
initTform = computeInitialEstimateFromINS(relTform, insDataTable(n - skipFrames:n, :));
% Compute rigid transformation that registers current point cloud with
% previous point cloud
relTform = pcregisterndt(ptCloud, ptCloudPrev, regGridSize, "InitialTransform", initTform);
% Update absolute transformation to reference frame (first point cloud)
absTform = rigidtform3d(absTform.A * relTform.A);
% Add current point cloud scan as a view to the view set
vSet = addView(vSet, viewId, absTform, "PointCloud", ptCloudOrig);
% Add a connection from the previous view to the current view representing
% the relative transformation between them
vSet = addConnection(vSet, viewId - 1, viewId, relTform);
% Extract the scan context descriptor from the point cloud
descriptor = scanContextDescriptor(ptCloudOrig);
% Add the descriptor to the loop closure detector
addDescriptor(loopDetector, viewId, descriptor);
% Detect loop closure candidates
loopViewId = detectLoop(loopDetector);
% A loop candidate was found
if ~isempty(loopViewId)
loopViewId = loopViewId(1);
% Retrieve point cloud from view set
loopView = findView(vSet, loopViewId);
ptCloudOrig = loopView.PointCloud;
% Process point cloud
ptCloudOld = processPointCloud(ptCloudOrig);
% Downsample point cloud
ptCloudOld = pcdownsample(ptCloudOld, "random", downsamplePercent);
% Use registration to estimate the relative pose
[relTform, ~, rmse] = pcregisterndt(ptCloud, ptCloudOld, regGridSize, "MaxIterations", 50);
acceptLoopClosure = rmse <= maxTolerableRMSE;
if acceptLoopClosure
% For simplicity, use a constant, small information matrix for
% loop closure edges
infoMat = 0.01 * eye(6);
% Add a connection corresponding to a loop closure
vSet = addConnection(vSet, loopViewId, viewId, relTform, infoMat);
end
end
viewId = viewId + 1;
ptCloudPrev = ptCloud;
initTform = relTform;
if n > 1 && mod(n, displayRate) == 1
hG = plot(vSet, "Parent", hAxBefore);
drawnow update;
end
end
% Create a pose graph from the view set by using the `createPoseGraph` method.
% The pose graph is a `digraph` object with:
% - Nodes containing the absolute pose of each view
% - Edges containing the relative pose constraints of each connection
G = createPoseGraph(vSet);
disp(G);
% In addition to the odometry connections between successive views, the view set now includes loop closure connections.
% For example, notice the new connections between the second loop traversal and the first loop traversal.
% These are loop closure connections.
% These can be identified as edges in the graph whose end nodes are not consecutive.
% Update axes limits to focus on loop closure connections
xlim(hAxBefore, [-50 50]);
ylim(hAxBefore, [-100 50]);
% Find and highlight loop closure connections
loopEdgeIds = find(abs(diff(G.Edges.EndNodes, 1, 2)) > 1);
highlight(hG, 'Edges', loopEdgeIds, 'EdgeColor', 'red', 'LineWidth', 3);
% Optimize the pose graph using `optimizePoseGraph`.
optimG = optimizePoseGraph(G, 'g2o-levenberg-marquardt');
vSetOptim = updateView(vSet, optimG.Nodes);
% Display the view set with optimized poses.
% Notice that the detected loops are now merged, resulting in a more accurate trajectory.
plot(vSetOptim, 'Parent', hAxBefore);
% The absolute poses in the optimized view set can now be used to build a more accurate map.
% Use the `pcalign` function to align the view set point clouds with the optimized view set absolute poses into a single point cloud map.
% Specify a grid size to control the resolution of the created point cloud map.
mapGridSize = 0.2;
ptClouds = vSetOptim.Views.PointCloud;
absPoses = vSetOptim.Views.AbsolutePose;
ptCloudMap = pcalign(ptClouds, absPoses, mapGridSize);
hFigAfter = figure('Name', 'View Set Display (after optimization)');
hAxAfter = axes(hFigAfter);
pcshow(ptCloudMap, 'Parent', hAxAfter);
% Overlay view set display
hold on;
plot(vSetOptim, 'Parent', hAxAfter);
makeFigurePublishFriendly(hFigAfter);
% Read Velodyne SLAM data from specified folder into a timetable.
function datasetTable = readDataset(dataFolder, pointCloudFilePattern)
% Create a file datastore to read in files in the right order
fileDS = fileDatastore(pointCloudFilePattern, 'ReadFcn', @readPointCloudFromFile);
% Extract the file list from the datastore
pointCloudFiles = fileDS.Files;
imuConfigFile = fullfile(dataFolder, 'scenario1', 'imu.cfg');
insDataTable = readINSConfigFile(imuConfigFile);
% Delete the bad row from the INS config file
insDataTable(1447, :) = [];
% Remove columns that will not be used
datasetTable = removevars(insDataTable, {'Num_Satellites', 'Latitude', 'Longitude', 'Altitude', 'Omega_Heading', 'Omega_Pitch', 'Omega_Roll', 'V_X', 'V_Y', 'V_ZDown'});
datasetTable = addvars(datasetTable, pointCloudFiles, 'Before', 1, 'NewVariableNames', "PointCloudFileName");
end
% Process a point cloud by removing points belonging to the ground plane and the ego vehicle.
function ptCloud = processPointCloud(ptCloudIn, method)
arguments
ptCloudIn (1, 1) pointCloud
method string {mustBeMember(method, ["planefit", "rangefloodfill"])} = "rangefloodfill"
end
isOrganized = ~ismatrix(ptCloudIn.Location);
if method == "rangefloodfill" && isOrganized
% Segment ground using floodfill on range image
groundFixedIdx = segmentGroundFromLidarData(ptCloudIn, "ElevationAngleDelta", 11);
else
% Segment ground as the dominant plane with reference normal
% vector pointing in positive z-direction
maxDistance = 0.4;
maxAngularDistance = 5;
referenceVector = [0 0 1];
[~, groundFixedIdx] = pcfitplane(ptCloudIn, maxDistance, referenceVector, maxAngularDistance);
end
if isOrganized
groundFixed = false(size(ptCloudIn.Location, 1), size(ptCloudIn.Location, 2));
else
groundFixed = false(ptCloudIn.Count, 1);
end
groundFixed(groundFixedIdx) = true;
% Segment ego vehicle as points within a given radius of sensor
sensorLocation = [0 0 0];
radius = 3.5;
egoFixedIdx = findNeighborsInRadius(ptCloudIn, sensorLocation, radius);
if isOrganized
egoFixed = false(size(ptCloudIn.Location, 1), size(ptCloudIn.Location, 2));
else
egoFixed = false(ptCloudIn.Count, 1);
end
egoFixed(egoFixedIdx) = true;
% Retain subset of point cloud without ground and ego vehicle
if isOrganized
indices = ~groundFixed & ~egoFixed;
else
indices = find(~groundFixed & ~egoFixed);
end
ptCloud = select(ptCloudIn, indices);
end
% Estimate an initial transformation for registration from INS readings.
function initTform = computeInitialEstimateFromINS(initTform, insData)
if isempty(insData)
return
end
% The INS readings are provided with X pointing to the front, Y to the left and Z up.
% Translation below accounts for transformation into the lidar frame.
insToLidarOffset = [0 -0.79 -1.73]; % See DATAFORMAT.txt
Tnow = [-insData.Y(end), insData.X(end), insData.Z(end)].' + insToLidarOffset';
Tbef = [-insData.Y(1), insData.X(1), insData.Z(1)].' + insToLidarOffset';
% Since the vehicle is expected to move along the ground, changes in roll and pitch are minimal.
% Ignore changes in roll and pitch, use heading only.
Rnow = rotmat(quaternion([insData.Heading(end) 0 0], 'euler', 'ZYX', 'point'), 'point');
Rbef = rotmat(quaternion([insData.Heading(1) 0 0], 'euler', 'ZYX', 'point'), 'point');
T = [Rbef Tbef; 0 0 0 1] \ [Rnow Tnow; 0 0 0 1];
initTform = rigidtform3d(T);
end
% Adjust figures so that screenshot captured by publish is correct.
function makeFigurePublishFriendly(hFig)
if ~isempty(hFig) && isvalid(hFig)
hFig.HandleVisibility = 'callback';
end
end