using CartographerSharp.IO;
using CartographerSharp.Mapping;
using CartographerSharp.Mapping.D2D;
using CartographerSharp.Transform;
using RobotNet10.RobotApp.SLAM.Cartographer;
using RobotNet10.Shared.Geometry;
using RobotNet10.Shared.Localization;
using RobotNet10.Shared.Numbers;
namespace RobotNet10.RobotApp.SLAM.Cartographer.Helpers;
///
/// Unified occupancy grid generator from MapBuilder submaps.
/// UNIFIED CONVENTION: All grids use ROS convention (row 0 = world BOTTOM, Y-axis pointing UP).
///
public static class OccupancyGridGenerator
{
#region Public API
///
/// Generate occupancy grid from MapBuilder using ROS convention.
///
/// MapBuilder containing submaps
/// Target grid resolution
/// Padding around map bounds
/// Optional logger
/// OccupancyGrid with ROS convention (row 0 = world bottom), or null if generation fails
public static OccupancyGrid? GenerateFromMapBuilder(
IMapBuilder mapBuilder,
double resolution,
double padding,
ILogger? logger = null)
{
try
{
var poseGraph = mapBuilder.PoseGraph;
var allSubmapData = poseGraph.GetAllSubmapData();
// Get TransformToMap and compute its inverse for applying to all poses
// This matches C++ xloc.cc behavior where poses are transformed using:
// transformedPose = GetTransformToMap().inverse() * pose
var transformToMap = poseGraph.GetTransformToMap();
var transformToMapInverse = transformToMap.Inverse();
// Collect all 2D submaps from pose graph (finished submaps)
// IMPORTANT: We store localToMapTransform (NOT globalPose) because:
// - GetCellCenter() returns coordinates in LOCAL MAP FRAME (trajectory local frame)
// - NOT in submap frame as previously assumed
// - localToMapTransform = TransformToMapInverse * LocalToGlobalTransform
// - This correctly transforms from local map frame to map frame
var submap2DList = new List<(Submap2D Submap, Rigid3d LocalToMapTransform)>();
int submapIndexDebug = 0;
foreach (var idDataRef in allSubmapData)
{
var submapData = idDataRef.Data;
if (submapData.Submap is Submap2D submap2D)
{
var submapId = idDataRef.Id;
var trajectoryId = submapId.TrajectoryId;
// FIX: Use LocalToGlobalTransform instead of SubmapData.Pose
// Cell coordinates from GetCellCenter() are in LOCAL MAP FRAME (L),
// so we need T_map_local = TransformToMapInverse * LocalToGlobalTransform
// Previously used: T_map_submap = TransformToMapInverse * SubmapData.Pose (WRONG)
var localToGlobal = poseGraph.GetLocalToGlobalTransform(trajectoryId);
Rigid3d localToMapTransform = transformToMapInverse * localToGlobal;
// Validate that the transform is valid (not NaN/Infinity)
if (!localToMapTransform.IsValid())
{
logger?.LogWarning(
"OccupancyGridGenerator: Submap {TrajectoryId}:{SubmapIndex} has invalid localToMapTransform, skipping",
submapId.TrajectoryId, submapId.SubmapIndex);
continue;
}
submap2DList.Add((submap2D, localToMapTransform));
submapIndexDebug++;
}
}
if (submap2DList.Count == 0)
{
logger?.LogWarning("OccupancyGridGenerator: No 2D submaps found");
return null;
}
// Calculate bounds from all submaps
var (minX, minY, maxX, maxY) = CalculateBoundsFromSubmaps(submap2DList);
// Validate bounds
if (minX >= maxX || minY >= maxY || double.IsInfinity(minX) || double.IsInfinity(maxX) ||
double.IsInfinity(minY) || double.IsInfinity(maxY))
{
logger?.LogWarning(
"OccupancyGridGenerator: Invalid bounds - minX={MinX}, minY={MinY}, maxX={MaxX}, maxY={MaxY}",
minX, minY, maxX, maxY);
return null;
}
// Add padding
var adjustedMinX = minX - padding;
var adjustedMinY = minY - padding;
var adjustedMaxX = maxX + padding;
var adjustedMaxY = maxY + padding;
var width = (int)Math.Ceiling((adjustedMaxX - adjustedMinX) / resolution);
var height = (int)Math.Ceiling((adjustedMaxY - adjustedMinY) / resolution);
// Set origin to bottom-left corner (cell (0,0)) according to ROS occupancy grid convention
var gridOriginPosition = new Vector3(adjustedMinX, adjustedMinY, 0.0);
var origin = new Pose
{
Position = gridOriginPosition,
Orientation = new Quaternion(0, 0, 0, 1)
};
var occupancyGrid = new OccupancyGrid(resolution, width, height, origin);
// Merge all submaps into occupancy grid
int totalCellsMerged = 0;
int totalCellsSkipped = 0;
foreach (var (submap2D, localToMapTransform) in submap2DList)
{
var (merged, skipped) = MergeSubmapIntoOccupancyGrid(submap2D, localToMapTransform, occupancyGrid, resolution);
totalCellsMerged += merged;
totalCellsSkipped += skipped;
}
return occupancyGrid;
}
catch (Exception ex)
{
logger?.LogError(ex, "OccupancyGridGenerator: Failed to generate occupancy grid");
return null;
}
}
#endregion
#region Bounds Calculation
///
/// Calculate bounds from submaps.
/// LocalToMapTransform transforms points from LOCAL MAP FRAME to MAP FRAME.
///
private static (double minX, double minY, double maxX, double maxY) CalculateBoundsFromSubmaps(
List<(Submap2D Submap, Rigid3d LocalToMapTransform)> submapList,
ILogger? logger = null)
{
// Delegate to the snapshot-aware overload with null snapshots
var extended = submapList
.Select(t => (t.Submap, t.LocalToMapTransform, (Grid2D.GridCellSnapshot?)null))
.ToList();
return CalculateBoundsFromSubmaps(extended, logger);
}
private static (double minX, double minY, double maxX, double maxY) CalculateBoundsFromSubmaps(
List<(Submap2D Submap, Rigid3d LocalToMapTransform, Grid2D.GridCellSnapshot? Snapshot)> submapList,
ILogger? logger = null)
{
double minX = double.MaxValue, minY = double.MaxValue;
double maxX = double.MinValue, maxY = double.MinValue;
int submapIdx = 0;
foreach (var (submap, localToMapTransform, snapshot) in submapList)
{
MapLimits limits;
CartographerSharp.Common.Math.Array2i croppedOffset;
CellLimits croppedLimits;
if (snapshot != null)
{
// Use snapshot data (active submap – thread-safe)
limits = snapshot.Limits;
snapshot.ComputeCroppedLimits(out croppedOffset, out croppedLimits);
}
else
{
var grid = submap.Grid;
if (grid == null) continue;
limits = grid.Limits;
grid.ComputeCroppedLimits(out croppedOffset, out croppedLimits);
}
if (croppedLimits.NumXCells <= 0 || croppedLimits.NumYCells <= 0)
continue;
// Get bounds of cropped (known) cells in submap local frame
var cornerIndices = new[]
{
new CartographerSharp.Common.Math.Array2i(croppedOffset.X, croppedOffset.Y),
new CartographerSharp.Common.Math.Array2i(croppedOffset.X + croppedLimits.NumXCells - 1, croppedOffset.Y),
new CartographerSharp.Common.Math.Array2i(croppedOffset.X, croppedOffset.Y + croppedLimits.NumYCells - 1),
new CartographerSharp.Common.Math.Array2i(croppedOffset.X + croppedLimits.NumXCells - 1, croppedOffset.Y + croppedLimits.NumYCells - 1)
};
double croppedMinX = double.MaxValue, croppedMinY = double.MaxValue;
double croppedMaxX = double.MinValue, croppedMaxY = double.MinValue;
foreach (var cornerIndex in cornerIndices)
{
var cellCenter = limits.GetCellCenter(cornerIndex);
croppedMinX = Math.Min(croppedMinX, cellCenter.X);
croppedMinY = Math.Min(croppedMinY, cellCenter.Y);
croppedMaxX = Math.Max(croppedMaxX, cellCenter.X);
croppedMaxY = Math.Max(croppedMaxY, cellCenter.Y);
}
// Transform corners from local map frame to map frame
var corners = new[]
{
new Vector2(croppedMinX, croppedMinY),
new Vector2(croppedMaxX, croppedMinY),
new Vector2(croppedMaxX, croppedMaxY),
new Vector2(croppedMinX, croppedMaxY)
};
double submapGlobalMinX = double.MaxValue, submapGlobalMinY = double.MaxValue;
double submapGlobalMaxX = double.MinValue, submapGlobalMaxY = double.MinValue;
foreach (var corner in corners)
{
var cornerPoint = new Vector3(corner.X, corner.Y, 0);
var mapCorner = localToMapTransform.TransformPoint(cornerPoint);
submapGlobalMinX = Math.Min(submapGlobalMinX, mapCorner.X);
submapGlobalMinY = Math.Min(submapGlobalMinY, mapCorner.Y);
submapGlobalMaxX = Math.Max(submapGlobalMaxX, mapCorner.X);
submapGlobalMaxY = Math.Max(submapGlobalMaxY, mapCorner.Y);
minX = Math.Min(minX, mapCorner.X);
minY = Math.Min(minY, mapCorner.Y);
maxX = Math.Max(maxX, mapCorner.X);
maxY = Math.Max(maxY, mapCorner.Y);
}
submapIdx++;
}
return (minX, minY, maxX, maxY);
}
#endregion
#region Submap Processing
///
/// Merge submap into occupancy grid using ROS convention.
/// Supports both ProbabilityGrid and TSDF2D grid types.
///
/// The submap to merge
/// Transform from LOCAL MAP FRAME to MAP FRAME
/// Target occupancy grid
/// Target grid resolution
/// (mergedCells, skippedCells) for statistics
private static (int mergedCells, int skippedCells) MergeSubmapIntoOccupancyGrid(
Submap2D submap2D,
Rigid3d localToMapTransform,
OccupancyGrid occupancyGrid,
double targetResolution)
{
var grid = submap2D.Grid;
if (grid == null)
return (0, 0);
// Dispatch to appropriate handler based on grid type
if (grid is ProbabilityGrid probabilityGrid)
{
return MergeSubmapIntoOccupancyGridFromProbabilityGrid(
probabilityGrid, localToMapTransform, occupancyGrid, targetResolution);
}
else if (grid is TSDF2D tsdfGrid)
{
return MergeSubmapIntoOccupancyGridFromTSDF(
tsdfGrid, localToMapTransform, occupancyGrid, targetResolution);
}
return (0, 0);
}
///
/// Merge ProbabilityGrid submap into occupancy grid.
///
/// The probability grid to merge
/// Transform from LOCAL MAP FRAME to MAP FRAME
/// Target occupancy grid
/// Target grid resolution
private static (int mergedCells, int skippedCells) MergeSubmapIntoOccupancyGridFromProbabilityGrid(
ProbabilityGrid probabilityGrid,
Rigid3d localToMapTransform,
OccupancyGrid occupancyGrid,
double targetResolution)
{
var limits = probabilityGrid.Limits;
int mergedCells = 0;
int skippedCells = 0;
// Use ComputeCroppedLimits to get only known cells bounds
probabilityGrid.ComputeCroppedLimits(out var croppedOffset, out var croppedLimits);
if (croppedLimits.NumXCells <= 0 || croppedLimits.NumYCells <= 0)
return (0, 0);
// Iterate through known cells region only
for (int y = 0; y < croppedLimits.NumYCells; y++)
{
for (int x = 0; x < croppedLimits.NumXCells; x++)
{
var cellIndex = new CartographerSharp.Common.Math.Array2i(croppedOffset.X + x, croppedOffset.Y + y);
// Skip unknown cells
if (!probabilityGrid.IsKnown(cellIndex))
{
skippedCells++;
continue;
}
// Get cell center in LOCAL MAP FRAME (NOT submap frame!)
// GetCellCenter returns coordinates relative to grid's Max, which is set
// based on LocalPose.Translation when the submap is created.
var cellCenter = limits.GetCellCenter(cellIndex);
var cellCenterInLocalFrame = new Vector3(cellCenter.X, cellCenter.Y, 0.0);
// Transform from LOCAL MAP FRAME to MAP FRAME
var mapPoint = localToMapTransform.TransformPoint(cellCenterInLocalFrame);
// Convert to occupancy grid coordinates
var gridX = (int)Math.Floor((mapPoint.X - occupancyGrid.Origin.Position.X) / targetResolution);
var gridY = (int)Math.Floor((mapPoint.Y - occupancyGrid.Origin.Position.Y) / targetResolution);
if (gridX >= 0 && gridX < occupancyGrid.Width && gridY >= 0 && gridY < occupancyGrid.Height)
{
// Get probability value from submap
var probability = probabilityGrid.GetProbability(cellIndex);
// Convert probability to occupancy value using Cartographer's texture-based conversion
var logOddsInteger = CartographerSharp.Mapping.SubmapProbabilityUtils.ProbabilityToLogOddsInteger(probability);
int delta = 128 - logOddsInteger;
byte textureValue = (byte)(delta > 0 ? delta : 0);
byte textureAlpha = (byte)(delta > 0 ? 0 : -delta);
// Convert texture value/alpha to occupancy value (0 = free, 100 = occupied, -1 = unknown)
sbyte occupancyValue;
if (textureAlpha > 0)
{
// Occupied space (high confidence)
occupancyValue = 100;
}
else if (textureValue >= 100)
{
// Free space (high confidence)
occupancyValue = 0;
}
else
{
// Skip ambiguous cells
skippedCells++;
continue;
}
var currentValue = occupancyGrid.GetCell(gridX, gridY);
// Merge logic: Protect known cells from being overwritten by unknown/ambiguous cells
bool shouldMerge = false;
if (currentValue == -1)
{
if (occupancyValue != -1)
{
shouldMerge = true;
}
}
else
{
if (occupancyValue != -1 && occupancyValue > currentValue)
{
shouldMerge = true;
}
}
if (shouldMerge)
{
occupancyGrid.Data[gridY * occupancyGrid.Width + gridX] = occupancyValue;
mergedCells++;
}
else
{
skippedCells++;
}
}
else
{
skippedCells++;
}
}
}
return (mergedCells, skippedCells);
}
///
/// Merge TSDF2D submap into occupancy grid.
/// TSDF convention:
/// - tsd > 0: Free space (away from obstacles)
/// - tsd < 0: Occupied space (inside/near obstacles)
/// - tsd = 0: On obstacle surface
///
/// The TSDF grid to merge
/// Transform from LOCAL MAP FRAME to MAP FRAME
/// Target occupancy grid
/// Target grid resolution
/// Optional configuration
private static (int mergedCells, int skippedCells) MergeSubmapIntoOccupancyGridFromTSDF(
TSDF2D tsdfGrid,
Rigid3d localToMapTransform,
OccupancyGrid occupancyGrid,
double targetResolution,
OccupancyGridConfiguration? config = null)
{
var limits = tsdfGrid.Limits;
int mergedCells = 0;
int skippedCells = 0;
// Get TSDF-specific thresholds from config
var freeThreshold = config?.TsdfFreeThreshold ?? 0.05;
var occupiedThreshold = config?.TsdfOccupiedThreshold ?? -0.02;
var minWeight = config?.TsdfMinWeight ?? 0.1;
// Use ComputeCroppedLimits to get only known cells bounds
tsdfGrid.ComputeCroppedLimits(out var croppedOffset, out var croppedLimits);
if (croppedLimits.NumXCells <= 0 || croppedLimits.NumYCells <= 0)
return (0, 0);
// Iterate through known cells region only
for (int y = 0; y < croppedLimits.NumYCells; y++)
{
for (int x = 0; x < croppedLimits.NumXCells; x++)
{
var cellIndex = new CartographerSharp.Common.Math.Array2i(croppedOffset.X + x, croppedOffset.Y + y);
// Skip unknown cells
if (!tsdfGrid.IsKnown(cellIndex))
{
skippedCells++;
continue;
}
// Get cell center in LOCAL MAP FRAME (NOT submap frame!)
var cellCenter = limits.GetCellCenter(cellIndex);
var cellCenterInLocalFrame = new Vector3(cellCenter.X, cellCenter.Y, 0.0);
// Transform from LOCAL MAP FRAME to MAP FRAME
var mapPoint = localToMapTransform.TransformPoint(cellCenterInLocalFrame);
// Convert to occupancy grid coordinates
var gridX = (int)Math.Floor((mapPoint.X - occupancyGrid.Origin.Position.X) / targetResolution);
var gridY = (int)Math.Floor((mapPoint.Y - occupancyGrid.Origin.Position.Y) / targetResolution);
if (gridX >= 0 && gridX < occupancyGrid.Width && gridY >= 0 && gridY < occupancyGrid.Height)
{
// Get TSD and weight from TSDF grid
var (tsd, weight) = tsdfGrid.GetTSDAndWeight(cellIndex);
// Skip cells with very low weight (not enough observations)
if (weight < minWeight)
{
skippedCells++;
continue;
}
// Convert TSD to occupancy value
// tsd > 0: Free space → occupancy = 0
// tsd < 0: Occupied → occupancy = 100
// tsd ≈ 0: Surface/ambiguous
sbyte occupancyValue;
if (tsd > freeThreshold)
{
// Free space (high confidence)
occupancyValue = 0;
}
else if (tsd < occupiedThreshold)
{
// Occupied space (high confidence)
occupancyValue = 100;
}
else
{
// Ambiguous (near surface) - skip
skippedCells++;
continue;
}
var currentValue = occupancyGrid.GetCell(gridX, gridY);
// Merge logic
bool shouldMerge = false;
if (currentValue == -1)
{
if (occupancyValue != -1)
{
shouldMerge = true;
}
}
else
{
if (occupancyValue != -1 && occupancyValue > currentValue)
{
shouldMerge = true;
}
}
if (shouldMerge)
{
occupancyGrid.Data[gridY * occupancyGrid.Width + gridX] = occupancyValue;
mergedCells++;
}
else
{
skippedCells++;
}
}
else
{
skippedCells++;
}
}
}
return (mergedCells, skippedCells);
}
#endregion
#region Helpers
///
/// Extract yaw angle in degrees from quaternion (for debugging)
///
private static double QuaternionToYawDegrees(RobotNet10.Shared.Geometry.Quaternion q)
{
// Yaw (Z-axis rotation) = atan2(2*(w*z + x*y), 1 - 2*(y*y + z*z))
var siny_cosp = 2.0 * (q.W * q.Z + q.X * q.Y);
var cosy_cosp = 1.0 - 2.0 * (q.Y * q.Y + q.Z * q.Z);
var yawRad = Math.Atan2(siny_cosp, cosy_cosp);
return yawRad * 180.0 / Math.PI;
}
#endregion
#region Texture-Based Generation
///
/// Generate occupancy grid from MapBuilder using texture-based approach.
/// This method uses DrawToSubmapTexture (from ProbabilityGrid/TSDF2D) and SubmapPainter
/// to generate the occupancy grid, matching the C++ xloc.cc implementation exactly.
///
/// MapBuilder containing submaps
/// Target grid resolution
/// Padding around map bounds
/// Optional logger
/// Optional occupancy grid configuration for threshold and post-processing
/// OccupancyGrid with ROS convention (row 0 = world bottom), or null if generation fails
public static OccupancyGrid? GenerateFromMapBuilderUsingTextures(
IMapBuilder mapBuilder,
double resolution,
double padding,
ILogger? logger = null,
OccupancyGridConfiguration? config = null)
{
try
{
var poseGraph = mapBuilder.PoseGraph;
var allSubmapData = poseGraph.GetAllSubmapData();
// Get TransformToMap and compute its inverse for applying to all poses
// This matches C++ xloc.cc behavior where poses are transformed using:
// transformedPose = GetTransformToMap().inverse() * pose
var transformToMap = poseGraph.GetTransformToMap();
var transformToMapInverse = transformToMap.Inverse();
// Collect submap slices
var submapSlices = new Dictionary();
int submapIndexDebug = 0;
foreach (var idDataRef in allSubmapData)
{
var submapData = idDataRef.Data;
if (submapData.Submap is Submap2D submap2D)
{
var submapId = idDataRef.Id;
var trajectoryId = submapId.TrajectoryId;
// Apply TransformToMap.Inverse() to transform from internal coordinate to map frame
// This matches C++ xloc.cc: GetTransformToMap().inverse() * pose
var localPose = submap2D.LocalPose;
Rigid3d globalPose = transformToMapInverse * submapData.Pose;
// Validate pose
if (!globalPose.IsValid())
{
logger?.LogWarning(
"OccupancyGridGenerator: Submap {TrajectoryId}:{SubmapIndex} has invalid pose, attempting fallback",
submapId.TrajectoryId, submapId.SubmapIndex);
var localToGlobal = poseGraph.GetLocalToGlobalTransform(trajectoryId);
var globalPoseComputed = transformToMapInverse * (localToGlobal * localPose);
if (globalPoseComputed.IsValid())
{
globalPose = globalPoseComputed;
}
else
{
logger?.LogError(
"OccupancyGridGenerator: Both poses invalid for submap {TrajectoryId}:{SubmapIndex}, skipping",
submapId.TrajectoryId, submapId.SubmapIndex);
continue;
}
}
// Create submap slice using DrawToSubmapTexture
try
{
var slice = SubmapPainter.CreateSubmapSlice(submap2D, globalPose);
if (slice.PixelData != null && slice.Width > 0 && slice.Height > 0)
{
submapSlices[submapId] = slice;
}
}
catch (Exception ex)
{
logger?.LogWarning(ex,
"OccupancyGridGenerator: Failed to create slice for submap {TrajectoryId}:{SubmapIndex}",
submapId.TrajectoryId, submapId.SubmapIndex);
}
submapIndexDebug++;
}
}
if (submapSlices.Count == 0)
{
logger?.LogWarning("OccupancyGridGenerator: No valid submap slices created");
return null;
}
// Paint all submap slices into combined image
var paintResult = SubmapPainter.PaintSubmapSlices(submapSlices, resolution);
if (paintResult == null)
{
logger?.LogWarning("OccupancyGridGenerator: PaintSubmapSlices returned null");
return null;
}
// Add padding to the result
var paddingPixels = (int)Math.Ceiling(padding / resolution);
var paddedWidth = paintResult.Width + 2 * paddingPixels;
var paddedHeight = paintResult.Height + 2 * paddingPixels;
// Create occupancy grid with padding
// paintResult.Origin.Y is the world Y at the TOP of the paint image
// OccupancyGrid origin must be at the BOTTOM-LEFT (ROS convention)
// Match C++: origin.y = (-height + originY_device) * resolution
// The bottom of the image in world Y = paintResult.Origin.Y - height * resolution
// Then subtract extra padding below
var origin = new Pose
{
Position = new Vector3(
paintResult.Origin.X - padding,
paintResult.Origin.Y - paintResult.Height * resolution - padding,
0.0),
Orientation = new Quaternion(0, 0, 0, 1)
};
var occupancyGrid = new OccupancyGrid(resolution, paddedWidth, paddedHeight, origin);
// Convert paint result to occupancy values with optional config
var occupancyValues = ConvertToOccupancyValuesWithConfig(paintResult, config);
// Copy occupancy values with padding offset
// Note: PaintSubmapSlices uses image coordinates (Y increases downward)
// OccupancyGrid uses ROS convention (row 0 = world bottom, Y increases upward)
// So we need to flip Y when copying
for (int py = 0; py < paintResult.Height; py++)
{
for (int px = 0; px < paintResult.Width; px++)
{
var srcIndex = py * paintResult.Width + px;
var occupancyValue = occupancyValues[srcIndex];
// Flip Y for ROS convention
var gridX = px + paddingPixels;
var gridY = (paintResult.Height - 1 - py) + paddingPixels;
if (gridX >= 0 && gridX < paddedWidth && gridY >= 0 && gridY < paddedHeight)
{
var dstIndex = gridY * paddedWidth + gridX;
occupancyGrid.Data[dstIndex] = occupancyValue;
}
}
}
// Apply post-processing if configured
if (config != null)
{
ApplyPostProcessing(occupancyGrid, config, logger);
}
logger?.LogDebug(
"OccupancyGridGenerator: Generated grid {W}x{H} from {Count} submaps using DrawToSubmapTexture",
paddedWidth, paddedHeight, submapSlices.Count);
return occupancyGrid;
}
catch (Exception ex)
{
logger?.LogError(ex, "OccupancyGridGenerator: Failed to generate occupancy grid using textures");
return null;
}
}
#endregion
#region Config-Based Conversion
///
/// Convert paint result to occupancy values using configuration thresholds.
///
private static sbyte[] ConvertToOccupancyValuesWithConfig(
PaintSubmapSlicesResult paintResult,
OccupancyGridConfiguration? config)
{
// Use default conversion if no config provided
if (config == null)
{
return SubmapPainter.ConvertToOccupancyValues(paintResult);
}
var occupancyValues = new sbyte[paintResult.Width * paintResult.Height];
for (int i = 0; i < paintResult.PixelData.Length; i++)
{
var pixel = paintResult.PixelData[i];
// Match C++ pixel format: (alpha << 24) | (intensity/color << 16) | (observed << 8) | 0
var color = (int)((pixel >> 16) & 0xFF); // RED channel = intensity/color
var observed = (int)((pixel >> 8) & 0xFF); // GREEN channel = observed flag
var alpha = (int)((pixel >> 24) & 0xFF); // ALPHA channel
if (observed == 0)
{
// Unknown cell - not observed
occupancyValues[i] = -1;
continue;
}
// Calculate texture-like values for threshold comparison
// logOddsInteger approximation from color: color=0 → high occupied, color=255 → high free
// delta = 128 - logOddsInteger
// If color is high (white/free), delta is positive → textureValue = delta, textureAlpha = 0
// If color is low (black/occupied), delta is negative → textureValue = 0, textureAlpha = -delta
// Approximate: textureValue ≈ color (for free space)
// textureAlpha ≈ 255 - color (for occupied space)
int textureValue = color;
int textureAlpha = alpha > 0 ? alpha : (color < 128 ? 128 - color : 0);
sbyte occupancyValue;
if (config.UseBinaryOutput)
{
// Binary output mode with configurable thresholds
if (textureAlpha > config.OccupiedSpaceThreshold)
{
// Occupied space
occupancyValue = 100;
}
else if (textureValue >= config.FreeSpaceThreshold)
{
// Free space
occupancyValue = 0;
}
else
{
// Ambiguous cell - check probability range
// Approximate probability from color: p ≈ 1 - color/255
double approxProbability = 1.0 - color / 255.0;
if (approxProbability >= config.AmbiguousRangeLower &&
approxProbability <= config.AmbiguousRangeUpper)
{
// In ambiguous range - use configured value
occupancyValue = config.AmbiguousCellValue;
}
else if (approxProbability < config.AmbiguousRangeLower)
{
// Below ambiguous range = more likely free
occupancyValue = 0;
}
else
{
// Above ambiguous range = more likely occupied
occupancyValue = 100;
}
}
}
else
{
// Gradient output mode
// Match C++ formula: occupancy = round((1 - color/255) * 100)
var occupancy = (int)Math.Round((1.0 - color / 255.0) * 100.0);
occupancyValue = (sbyte)Math.Clamp(occupancy, 0, 100);
}
occupancyValues[i] = occupancyValue;
}
return occupancyValues;
}
#endregion
#region Post-Processing
///
/// Apply post-processing operations based on configuration.
///
private static void ApplyPostProcessing(
OccupancyGrid grid,
OccupancyGridConfiguration config,
ILogger? logger)
{
// Apply median filter first (noise reduction)
if (config.EnableMedianFilter)
{
ApplyMedianFilter(grid, config.MedianFilterKernelSize);
logger?.LogDebug("OccupancyGridGenerator: Applied median filter with kernel size {Size}",
config.MedianFilterKernelSize);
}
// Apply wall thinning (erosion)
if (config.EnableWallThinning && config.WallThinningIterations > 0)
{
for (int i = 0; i < config.WallThinningIterations; i++)
{
ApplyWallThinning(grid, config.MinWallThicknessPixels);
}
logger?.LogDebug("OccupancyGridGenerator: Applied wall thinning with {Iterations} iterations",
config.WallThinningIterations);
}
}
///
/// Apply median filter to reduce noise.
///
private static void ApplyMedianFilter(OccupancyGrid grid, int kernelSize)
{
if (kernelSize < 3 || kernelSize % 2 == 0)
kernelSize = 3; // Ensure odd kernel size
var halfKernel = kernelSize / 2;
var newData = new sbyte[grid.Data.Length];
Array.Copy(grid.Data, newData, grid.Data.Length);
var neighbors = new List(kernelSize * kernelSize);
for (int y = halfKernel; y < grid.Height - halfKernel; y++)
{
for (int x = halfKernel; x < grid.Width - halfKernel; x++)
{
neighbors.Clear();
// Collect neighbors
for (int ky = -halfKernel; ky <= halfKernel; ky++)
{
for (int kx = -halfKernel; kx <= halfKernel; kx++)
{
var idx = (y + ky) * grid.Width + (x + kx);
var value = grid.Data[idx];
if (value != -1) // Only consider known cells
{
neighbors.Add(value);
}
}
}
// Apply median if enough neighbors
if (neighbors.Count >= kernelSize)
{
neighbors.Sort();
var median = neighbors[neighbors.Count / 2];
newData[y * grid.Width + x] = median;
}
}
}
Array.Copy(newData, grid.Data, grid.Data.Length);
}
///
/// Apply morphological erosion to thin walls.
/// Only erodes occupied cells (value = 100) that have free neighbors.
///
private static void ApplyWallThinning(OccupancyGrid grid, int minThickness)
{
var newData = new sbyte[grid.Data.Length];
Array.Copy(grid.Data, newData, grid.Data.Length);
// 4-connectivity erosion kernel (up, down, left, right)
int[] dx = { 0, 0, -1, 1 };
int[] dy = { -1, 1, 0, 0 };
for (int y = 1; y < grid.Height - 1; y++)
{
for (int x = 1; x < grid.Width - 1; x++)
{
var idx = y * grid.Width + x;
var value = grid.Data[idx];
// Only process occupied cells
if (value != 100)
continue;
// Check if this cell should be eroded
// Count free neighbors
int freeNeighbors = 0;
int occupiedNeighbors = 0;
for (int d = 0; d < 4; d++)
{
var nx = x + dx[d];
var ny = y + dy[d];
var neighborIdx = ny * grid.Width + nx;
var neighborValue = grid.Data[neighborIdx];
if (neighborValue == 0)
freeNeighbors++;
else if (neighborValue == 100)
occupiedNeighbors++;
}
// Erode if:
// 1. Has at least one free neighbor (is on wall boundary)
// 2. Has enough occupied neighbors to maintain minimum thickness
if (freeNeighbors > 0 && occupiedNeighbors >= minThickness)
{
// Check perpendicular thickness to ensure we don't break thin walls
bool canErode = true;
// Check horizontal thickness
if (freeNeighbors == 1 || freeNeighbors == 2)
{
int hThickness = 1;
for (int tx = x - 1; tx >= 0 && grid.Data[y * grid.Width + tx] == 100; tx--)
hThickness++;
for (int tx = x + 1; tx < grid.Width && grid.Data[y * grid.Width + tx] == 100; tx++)
hThickness++;
int vThickness = 1;
for (int ty = y - 1; ty >= 0 && grid.Data[ty * grid.Width + x] == 100; ty--)
vThickness++;
for (int ty = y + 1; ty < grid.Height && grid.Data[ty * grid.Width + x] == 100; ty++)
vThickness++;
// Don't erode if it would make wall too thin
if (Math.Min(hThickness, vThickness) <= minThickness)
canErode = false;
}
if (canErode)
{
newData[idx] = 0; // Erode to free space
}
}
}
}
Array.Copy(newData, grid.Data, grid.Data.Length);
}
#endregion
#region Log-Odds Based Generation
///
/// Generate occupancy grid from MapBuilder using log-odds summation (Bayesian approach).
/// This method directly reads probability values from each submap's ProbabilityGrid,
/// sums log-odds for overlapping cells, and converts back to occupancy values.
/// Provides clearer free/occupied distinction compared to texture-based blending.
///
/// MapBuilder containing submaps
/// Target grid resolution
/// Padding around map bounds
/// Optional logger
/// Configuration for thresholds and log-odds clamp
/// OccupancyGrid with ROS convention (row 0 = world bottom), or null if generation fails
public static OccupancyGrid? GenerateFromMapBuilderUsingLogOdds(
IMapBuilder mapBuilder,
double resolution,
double padding,
ILogger? logger = null,
OccupancyGridConfiguration? config = null,
MapById? prefetchedSubmapData = null,
Rigid3d? prefetchedTransformToMap = null)
{
try
{
var poseGraph = mapBuilder.PoseGraph;
var allSubmapData = prefetchedSubmapData ?? poseGraph.GetAllSubmapData();
// Get TransformToMap inverse for coordinate transformation
// NOTE: During ScanMapping, TransformToMap should be Identity.
// The laser scan and robot pose from GlobalTrajectoryBuilder use localToGlobal only
// (optimization frame), so TransformToMap.Inverse() must equal Identity for alignment.
// If non-identity, the occupancy grid will be in a different frame from pose/laser.
var transformToMap = prefetchedTransformToMap ?? poseGraph.GetTransformToMap();
var transformToMapInverse = transformToMap.Inverse();
// Warn if TransformToMap is non-identity (could cause frame mismatch with pose/laser)
if (Math.Abs(transformToMap.Translation.X) > 0.001 ||
Math.Abs(transformToMap.Translation.Y) > 0.001 ||
Math.Abs(transformToMap.Translation.Z) > 0.001 ||
Math.Abs(transformToMap.Rotation.X) > 0.001 ||
Math.Abs(transformToMap.Rotation.Y) > 0.001 ||
Math.Abs(transformToMap.Rotation.Z) > 0.001)
{
logger?.LogWarning(
"OccupancyGridGenerator: TransformToMap is NON-IDENTITY! " +
"Grid frame may not match pose/laser frame. " +
"T=[{TX:F4},{TY:F4},{TZ:F4}], R=[{RW:F4},{RX:F4},{RY:F4},{RZ:F4}]",
transformToMap.Translation.X, transformToMap.Translation.Y, transformToMap.Translation.Z,
transformToMap.Rotation.W, transformToMap.Rotation.X, transformToMap.Rotation.Y, transformToMap.Rotation.Z);
}
// Collect all 2D submaps with their local-to-map transforms.
// IMPORTANT: We use LocalToGlobalTransform (NOT SubmapData.Pose) because:
// - GetCellCenter() returns coordinates in LOCAL MAP FRAME (trajectory local frame)
// - NOT in submap frame as previously assumed
// - localToMapTransform = TransformToMapInverse * LocalToGlobalTransform
// For active (non-finished) submaps, snapshot the grid cell data to avoid
// race conditions with concurrent InsertRangeData on the sensor thread.
// Finished submaps are immutable and can be read directly.
var submap2DList = new List<(Submap2D Submap, Rigid3d LocalToMapTransform, Grid2D.GridCellSnapshot? Snapshot)>();
foreach (var idDataRef in allSubmapData)
{
var submapData = idDataRef.Data;
if (submapData.Submap is Submap2D submap2D)
{
var submapId = idDataRef.Id;
var trajectoryId = submapId.TrajectoryId;
// FIX: Use LocalToGlobalTransform instead of SubmapData.Pose
// Cell coordinates from GetCellCenter() are in LOCAL MAP FRAME (L),
// so we need T_map_local = TransformToMapInverse * LocalToGlobalTransform
var localToGlobal = poseGraph.GetLocalToGlobalTransform(trajectoryId);
Rigid3d localToMapTransform = transformToMapInverse * localToGlobal;
// Validate transform
if (!localToMapTransform.IsValid())
{
logger?.LogWarning(
"OccupancyGridGenerator: Skipping submap {TrajectoryId}:{SubmapIndex} with invalid localToMapTransform",
submapId.TrajectoryId, submapId.SubmapIndex);
continue;
}
// Snapshot active submaps to decouple from sensor thread writes
Grid2D.GridCellSnapshot? snapshot = null;
if (!submap2D.InsertionFinished && submap2D.Grid != null)
{
snapshot = submap2D.Grid.SnapshotCellData();
}
submap2DList.Add((submap2D, localToMapTransform, snapshot));
}
}
if (submap2DList.Count == 0)
{
logger?.LogWarning("OccupancyGridGenerator: No 2D submaps found for log-odds generation");
return null;
}
// Log per-submap details for debugging
for (int si = 0; si < submap2DList.Count; si++)
{
var (sm, localToMap, snap) = submap2DList[si];
var smGrid = sm.Grid;
var gridType = smGrid?.GetType().Name ?? "null";
var numCellsX = smGrid?.Limits.CellLimits.NumXCells ?? 0;
var numCellsY = smGrid?.Limits.CellLimits.NumYCells ?? 0;
// Get local pose and grid max point for debugging
var localPose = sm.LocalPose;
var gridMaxX = smGrid?.Limits.Max.X ?? 0;
var gridMaxY = smGrid?.Limits.Max.Y ?? 0;
}
// Calculate bounds from all submaps
var (minX, minY, maxX, maxY) = CalculateBoundsFromSubmaps(submap2DList, logger);
// Validate bounds
if (minX >= maxX || minY >= maxY || double.IsInfinity(minX) || double.IsInfinity(maxX) ||
double.IsInfinity(minY) || double.IsInfinity(maxY))
{
logger?.LogWarning(
"OccupancyGridGenerator: Invalid bounds for log-odds generation: " +
"min=[{MinX:F3},{MinY:F3}], max=[{MaxX:F3},{MaxY:F3}]",
minX, minY, maxX, maxY);
return null;
}
// Add padding
var adjustedMinX = minX - padding;
var adjustedMinY = minY - padding;
var adjustedMaxX = maxX + padding;
var adjustedMaxY = maxY + padding;
var width = (int)Math.Ceiling((adjustedMaxX - adjustedMinX) / resolution);
var height = (int)Math.Ceiling((adjustedMaxY - adjustedMinY) / resolution);
// Set origin to bottom-left corner (ROS convention)
var gridOriginPosition = new Vector3(adjustedMinX, adjustedMinY, 0.0);
var origin = new Pose
{
Position = gridOriginPosition,
Orientation = new Quaternion(0, 0, 0, 1)
};
// Create accumulation buffers
// logOddsSum: sum of log-odds for each cell
// observationCount: number of observations per cell
var logOddsSum = new double[width * height];
var observationCount = new int[width * height];
// Initialize to zero (prior = 0.5, logOdds = 0)
Array.Fill(logOddsSum, 0.0);
Array.Fill(observationCount, 0);
// Get log-odds clamp value
var logOddsClamp = config?.LogOddsClamp ?? 10.0;
// Merge all submaps using log-odds summation.
// Active submaps use their pre-captured snapshot to avoid racing with
// concurrent InsertRangeData; finished submaps read the grid directly.
foreach (var (submap2D, localToMapTransform, snapshot) in submap2DList)
{
if (snapshot != null)
{
// Active submap → use thread-safe snapshot
MergeSnapshotUsingLogOdds(
snapshot, localToMapTransform,
logOddsSum, observationCount,
width, height,
adjustedMinX, adjustedMinY,
resolution, logOddsClamp);
}
else
{
// Finished submap → grid is immutable, safe to read directly
MergeSubmapUsingLogOdds(
submap2D, localToMapTransform,
logOddsSum, observationCount,
width, height,
adjustedMinX, adjustedMinY,
resolution, logOddsClamp, config);
}
}
// Debug: count total observations
int totalObservations = 0;
int observedCells = 0;
double minLogOdds = double.MaxValue;
double maxLogOdds = double.MinValue;
for (int i = 0; i < observationCount.Length; i++)
{
if (observationCount[i] > 0)
{
observedCells++;
totalObservations += observationCount[i];
minLogOdds = Math.Min(minLogOdds, logOddsSum[i]);
maxLogOdds = Math.Max(maxLogOdds, logOddsSum[i]);
}
}
// Convert log-odds to occupancy values
var occupancyGrid = new OccupancyGrid(resolution, width, height, origin);
var stats = ConvertLogOddsToOccupancy(
logOddsSum, observationCount,
occupancyGrid, config, logger);
// Apply post-processing if configured
if (config != null)
{
ApplyPostProcessing(occupancyGrid, config, logger);
}
return occupancyGrid;
}
catch (Exception ex)
{
logger?.LogError(ex, "OccupancyGridGenerator: Failed to generate occupancy grid using log-odds");
return null;
}
}
///
/// Merge a single submap into log-odds accumulation buffers.
/// Supports both ProbabilityGrid and TSDF2D grid types.
///
/// The submap to merge
/// Transform from LOCAL MAP FRAME to MAP FRAME
private static void MergeSubmapUsingLogOdds(
Submap2D submap2D,
Rigid3d localToMapTransform,
double[] logOddsSum,
int[] observationCount,
int gridWidth,
int gridHeight,
double originX,
double originY,
double resolution,
double logOddsClamp,
OccupancyGridConfiguration? config = null)
{
var grid = submap2D.Grid;
if (grid == null)
return;
// Dispatch to appropriate handler based on grid type
if (grid is ProbabilityGrid probabilityGrid)
{
MergeSubmapUsingLogOddsFromProbabilityGrid(
probabilityGrid, localToMapTransform, logOddsSum, observationCount,
gridWidth, gridHeight, originX, originY, resolution, logOddsClamp);
}
else if (grid is TSDF2D tsdfGrid)
{
MergeSubmapUsingLogOddsFromTSDF(
tsdfGrid, localToMapTransform, logOddsSum, observationCount,
gridWidth, gridHeight, originX, originY, resolution, logOddsClamp, config);
}
}
///
/// Merge ProbabilityGrid submap into log-odds accumulation buffers.
///
/// The probability grid to merge
/// Transform from LOCAL MAP FRAME to MAP FRAME
private static void MergeSubmapUsingLogOddsFromProbabilityGrid(
ProbabilityGrid probabilityGrid,
Rigid3d localToMapTransform,
double[] logOddsSum,
int[] observationCount,
int gridWidth,
int gridHeight,
double originX,
double originY,
double resolution,
double logOddsClamp)
{
var limits = probabilityGrid.Limits;
// Use ComputeCroppedLimits to get only known cells bounds
probabilityGrid.ComputeCroppedLimits(out var croppedOffset, out var croppedLimits);
if (croppedLimits.NumXCells <= 0 || croppedLimits.NumYCells <= 0)
return;
// Iterate through known cells region only
for (int y = 0; y < croppedLimits.NumYCells; y++)
{
for (int x = 0; x < croppedLimits.NumXCells; x++)
{
var cellIndex = new CartographerSharp.Common.Math.Array2i(croppedOffset.X + x, croppedOffset.Y + y);
// Skip unknown cells
if (!probabilityGrid.IsKnown(cellIndex))
continue;
// Get cell center in LOCAL MAP FRAME (NOT submap frame!)
var cellCenter = limits.GetCellCenter(cellIndex);
var cellCenterInLocalFrame = new Vector3(cellCenter.X, cellCenter.Y, 0.0);
// Transform from LOCAL MAP FRAME to MAP FRAME
var mapPoint = localToMapTransform.TransformPoint(cellCenterInLocalFrame);
// Convert to grid coordinates (ROS convention)
var gridX = (int)Math.Floor((mapPoint.X - originX) / resolution);
var gridY = (int)Math.Floor((mapPoint.Y - originY) / resolution);
if (gridX >= 0 && gridX < gridWidth && gridY >= 0 && gridY < gridHeight)
{
// Get probability value from submap
var probability = probabilityGrid.GetProbability(cellIndex);
// Clamp probability to avoid log(0) or log(infinity)
probability = Math.Clamp(probability, 0.001, 0.999);
// Convert probability to log-odds: log(p / (1 - p))
var logOdds = Math.Log(probability / (1.0 - probability));
// Clamp log-odds to prevent extreme values
logOdds = Math.Clamp(logOdds, -logOddsClamp, logOddsClamp);
// Accumulate
var idx = gridY * gridWidth + gridX;
logOddsSum[idx] += logOdds;
observationCount[idx]++;
}
}
}
}
///
/// Merge a GridCellSnapshot (from an active submap) into log-odds accumulation buffers.
/// Works identically to MergeSubmapUsingLogOddsFromProbabilityGrid but reads from the
/// snapshot arrays rather than the live grid, eliminating race conditions.
///
/// The grid cell snapshot to merge
/// Transform from LOCAL MAP FRAME to MAP FRAME
private static void MergeSnapshotUsingLogOdds(
Grid2D.GridCellSnapshot snapshot,
Rigid3d localToMapTransform,
double[] logOddsSum,
int[] observationCount,
int gridWidth,
int gridHeight,
double originX,
double originY,
double resolution,
double logOddsClamp)
{
var limits = snapshot.Limits;
snapshot.ComputeCroppedLimits(out var croppedOffset, out var croppedLimits);
if (croppedLimits.NumXCells <= 0 || croppedLimits.NumYCells <= 0)
return;
var cells = snapshot.Cells;
var numXCells = limits.CellLimits.NumXCells;
for (int y = 0; y < croppedLimits.NumYCells; y++)
{
for (int x = 0; x < croppedLimits.NumXCells; x++)
{
var cellIndex = new CartographerSharp.Common.Math.Array2i(croppedOffset.X + x, croppedOffset.Y + y);
if (!snapshot.IsKnown(cellIndex))
continue;
// Read cell value from snapshot and convert to probability
// (matches ProbabilityGrid.GetProbability: ValueToCorrespondenceCost → CorrespondenceCostToProbability)
var flatIndex = numXCells * cellIndex.Y + cellIndex.X;
if (flatIndex < 0 || flatIndex >= cells.Length)
continue;
var value = cells[flatIndex];
const ushort kUpdateMarker = (ushort)(1u << 15);
if (value >= kUpdateMarker)
value -= kUpdateMarker;
if (value == 0) continue; // unknown
var probability = ProbabilityValues.CorrespondenceCostToProbability(
ProbabilityValues.ValueToCorrespondenceCost(value));
// Get cell center in LOCAL MAP FRAME (NOT submap frame!)
var cellCenter = limits.GetCellCenter(cellIndex);
var mapPoint = localToMapTransform.TransformPoint(new Vector3(cellCenter.X, cellCenter.Y, 0.0));
var gridX = (int)Math.Floor((mapPoint.X - originX) / resolution);
var gridY = (int)Math.Floor((mapPoint.Y - originY) / resolution);
if (gridX >= 0 && gridX < gridWidth && gridY >= 0 && gridY < gridHeight)
{
probability = Math.Clamp(probability, 0.001, 0.999);
var logOdds = Math.Log(probability / (1.0 - probability));
logOdds = Math.Clamp(logOdds, -logOddsClamp, logOddsClamp);
var idx = gridY * gridWidth + gridX;
logOddsSum[idx] += logOdds;
observationCount[idx]++;
}
}
}
}
///
/// Merge TSDF2D submap into log-odds accumulation buffers.
/// Uses TSDF-specific thresholds for direct classification:
/// tsd > TsdfFreeThreshold → FREE (strong negative logOdds)
/// tsd < TsdfOccupiedThreshold → OCCUPIED (strong positive logOdds)
/// else → near surface, use linear mapping
/// Weight is used only as a minimum confidence filter.
///
/// The TSDF grid to merge
/// Transform from LOCAL MAP FRAME to MAP FRAME
private static void MergeSubmapUsingLogOddsFromTSDF(
TSDF2D tsdfGrid,
Rigid3d localToMapTransform,
double[] logOddsSum,
int[] observationCount,
int gridWidth,
int gridHeight,
double originX,
double originY,
double resolution,
double logOddsClamp,
OccupancyGridConfiguration? config = null)
{
var limits = tsdfGrid.Limits;
// Get TSDF-specific thresholds from config
var tsdfMaxTsd = config?.TsdfMaxTsd ?? 0.3;
var tsdfMinWeight = config?.TsdfMinWeight ?? 0.1;
var tsdfFreeThreshold = config?.TsdfFreeThreshold ?? 0.05;
var tsdfOccupiedThreshold = config?.TsdfOccupiedThreshold ?? -0.02;
// Use ComputeCroppedLimits to get only known cells bounds
tsdfGrid.ComputeCroppedLimits(out var croppedOffset, out var croppedLimits);
if (croppedLimits.NumXCells <= 0 || croppedLimits.NumYCells <= 0)
return;
// Iterate through known cells region only
for (int y = 0; y < croppedLimits.NumYCells; y++)
{
for (int x = 0; x < croppedLimits.NumXCells; x++)
{
var cellIndex = new CartographerSharp.Common.Math.Array2i(croppedOffset.X + x, croppedOffset.Y + y);
// Skip unknown cells
if (!tsdfGrid.IsKnown(cellIndex))
continue;
// Get cell center in LOCAL MAP FRAME (NOT submap frame!)
var cellCenter = limits.GetCellCenter(cellIndex);
var cellCenterInLocalFrame = new Vector3(cellCenter.X, cellCenter.Y, 0.0);
// Transform from LOCAL MAP FRAME to MAP FRAME
var mapPoint = localToMapTransform.TransformPoint(cellCenterInLocalFrame);
// Convert to grid coordinates (ROS convention)
var gridX = (int)Math.Floor((mapPoint.X - originX) / resolution);
var gridY = (int)Math.Floor((mapPoint.Y - originY) / resolution);
if (gridX >= 0 && gridX < gridWidth && gridY >= 0 && gridY < gridHeight)
{
// Get TSD and weight from TSDF grid
var (tsd, weight) = tsdfGrid.GetTSDAndWeight(cellIndex);
// Skip cells with very low weight (not enough observations to be reliable)
if (weight < tsdfMinWeight)
continue;
// Convert TSD directly to probability using thresholds
// TSDF convention:
// tsd > 0: Free space (away from obstacles)
// tsd < 0: Occupied space (inside/near obstacles)
// tsd = 0: On obstacle surface
double probability;
if (tsd > tsdfFreeThreshold)
{
// Clearly free: map tsd [freeThreshold, maxTsd] → probability [0.2, 0.01]
var t = Math.Clamp((tsd - tsdfFreeThreshold) / (tsdfMaxTsd - tsdfFreeThreshold), 0.0, 1.0);
probability = 0.2 - t * 0.19; // 0.2 → 0.01
}
else if (tsd < tsdfOccupiedThreshold)
{
// Clearly occupied: map tsd [occupiedThreshold, -maxTsd] → probability [0.8, 0.99]
var t = Math.Clamp((tsdfOccupiedThreshold - tsd) / (tsdfMaxTsd + tsdfOccupiedThreshold), 0.0, 1.0);
probability = 0.8 + t * 0.19; // 0.8 → 0.99
}
else
{
// Near surface: steep linear mapping between thresholds
// tsd = occupiedThreshold → probability = 0.95 (deep into occupied territory)
// tsd = freeThreshold → probability = 0.05 (deep into free territory)
// This steep mapping minimizes cells in ambiguous range [0.35, 0.65]
var range = tsdfFreeThreshold - tsdfOccupiedThreshold;
if (range > 0)
{
var t = (tsd - tsdfOccupiedThreshold) / range; // 0 at occupied, 1 at free
probability = 0.95 - t * 0.9; // 0.95 → 0.05
}
else
{
probability = 0.5;
}
}
// Clamp probability to valid range
probability = Math.Clamp(probability, 0.001, 0.999);
// Convert probability to log-odds
var logOdds = Math.Log(probability / (1.0 - probability));
// Clamp log-odds
logOdds = Math.Clamp(logOdds, -logOddsClamp, logOddsClamp);
// Accumulate
var idx = gridY * gridWidth + gridX;
logOddsSum[idx] += logOdds;
observationCount[idx]++;
}
}
}
}
///
/// Convert accumulated log-odds to occupancy values.
/// Returns statistics about the conversion.
///
private static (int freeCells, int occupiedCells, int unknownCells) ConvertLogOddsToOccupancy(
double[] logOddsSum,
int[] observationCount,
OccupancyGrid occupancyGrid,
OccupancyGridConfiguration? config,
ILogger? logger = null)
{
var useBinary = config?.UseBinaryOutput ?? true;
var ambiguousValue = config?.AmbiguousCellValue ?? (sbyte)-1;
var ambiguousLower = config?.AmbiguousRangeLower ?? 0.35;
var ambiguousUpper = config?.AmbiguousRangeUpper ?? 0.65;
var useAverage = config?.UseLogOddsAverage ?? true;
int freeCells = 0, occupiedCells = 0, unknownCells = 0;
double minProb = 1.0, maxProb = 0.0;
for (int i = 0; i < logOddsSum.Length; i++)
{
if (observationCount[i] == 0)
{
// No observations - unknown
occupancyGrid.Data[i] = -1;
unknownCells++;
continue;
}
// Convert log-odds to probability
// If useAverage=true, divide by observation count to get average log-odds
// This prevents amplification when a cell is observed by many submaps
var logOdds = useAverage ? logOddsSum[i] / observationCount[i] : logOddsSum[i];
// P = 1 / (1 + exp(-logOdds))
var probability = 1.0 / (1.0 + Math.Exp(-logOdds));
minProb = Math.Min(minProb, probability);
maxProb = Math.Max(maxProb, probability);
// Convert probability to occupancy value [0, 100]
// probability = 0 → occupancy = 0 (free)
// probability = 1 → occupancy = 100 (occupied)
var occupancy = (int)Math.Round(probability * 100.0);
if (useBinary)
{
// Binary output mode using ambiguous range thresholds directly
// probability < ambiguousLower → FREE (high confidence free)
// probability > ambiguousUpper → OCCUPIED (high confidence occupied)
// else → AMBIGUOUS
if (probability < ambiguousLower)
{
occupancyGrid.Data[i] = 0;
freeCells++;
}
else if (probability > ambiguousUpper)
{
occupancyGrid.Data[i] = 100;
occupiedCells++;
}
else
{
// Ambiguous range
occupancyGrid.Data[i] = ambiguousValue;
unknownCells++;
}
}
else
{
// Gradient output mode
occupancyGrid.Data[i] = (sbyte)Math.Clamp(occupancy, 0, 100);
if (occupancy < 35)
freeCells++;
else if (occupancy > 65)
occupiedCells++;
else
unknownCells++;
}
}
return (freeCells, occupiedCells, unknownCells);
}
#endregion
#region Max Probability Generation
///
/// Generate occupancy grid using max probability strategy.
/// Takes the maximum (most occupied) probability for overlapping cells.
/// Conservative approach good for navigation safety.
///
public static OccupancyGrid? GenerateFromMapBuilderUsingMaxProbability(
IMapBuilder mapBuilder,
double resolution,
double padding,
ILogger? logger = null,
OccupancyGridConfiguration? config = null)
{
try
{
var poseGraph = mapBuilder.PoseGraph;
var allSubmapData = poseGraph.GetAllSubmapData();
var transformToMap = poseGraph.GetTransformToMap();
var transformToMapInverse = transformToMap.Inverse();
// IMPORTANT: We store localToMapTransform (NOT globalPose) because:
// - GetCellCenter() returns coordinates in LOCAL MAP FRAME (trajectory local frame)
// - NOT in submap frame as previously assumed
// - localToMapTransform = TransformToMapInverse * LocalToGlobalTransform
// - This correctly transforms from local map frame to map frame
var submap2DList = new List<(Submap2D Submap, Rigid3d LocalToMapTransform)>();
foreach (var idDataRef in allSubmapData)
{
var submapData = idDataRef.Data;
if (submapData.Submap is Submap2D submap2D)
{
var submapId = idDataRef.Id;
var trajectoryId = submapId.TrajectoryId;
// FIX: Use LocalToGlobalTransform instead of SubmapData.Pose
// Cell coordinates from GetCellCenter() are in LOCAL MAP FRAME (L),
// so we need T_map_local = TransformToMapInverse * LocalToGlobalTransform
// Previously used: T_map_submap = TransformToMapInverse * SubmapData.Pose (WRONG)
var localToGlobal = poseGraph.GetLocalToGlobalTransform(trajectoryId);
Rigid3d localToMapTransform = transformToMapInverse * localToGlobal;
if (!localToMapTransform.IsValid())
{
continue;
}
submap2DList.Add((submap2D, localToMapTransform));
}
}
if (submap2DList.Count == 0)
return null;
var (minX, minY, maxX, maxY) = CalculateBoundsFromSubmaps(submap2DList);
if (minX >= maxX || minY >= maxY || double.IsInfinity(minX) || double.IsInfinity(maxX) ||
double.IsInfinity(minY) || double.IsInfinity(maxY))
return null;
var adjustedMinX = minX - padding;
var adjustedMinY = minY - padding;
var adjustedMaxX = maxX + padding;
var adjustedMaxY = maxY + padding;
var width = (int)Math.Ceiling((adjustedMaxX - adjustedMinX) / resolution);
var height = (int)Math.Ceiling((adjustedMaxY - adjustedMinY) / resolution);
var origin = new Pose
{
Position = new Vector3(adjustedMinX, adjustedMinY, 0.0),
Orientation = new Quaternion(0, 0, 0, 1)
};
// Max probability buffer (initialized to -1 meaning no observation)
var maxProbability = new double[width * height];
Array.Fill(maxProbability, -1.0);
foreach (var (submap2D, localToMapTransform) in submap2DList)
{
MergeSubmapUsingMaxProbability(
submap2D, localToMapTransform,
maxProbability,
width, height,
adjustedMinX, adjustedMinY,
resolution);
}
var occupancyGrid = new OccupancyGrid(resolution, width, height, origin);
ConvertMaxProbabilityToOccupancy(maxProbability, occupancyGrid, config);
if (config != null)
{
ApplyPostProcessing(occupancyGrid, config, logger);
}
logger?.LogDebug(
"OccupancyGridGenerator: Generated grid {W}x{H} from {Count} submaps using max probability",
width, height, submap2DList.Count);
return occupancyGrid;
}
catch (Exception ex)
{
logger?.LogError(ex, "OccupancyGridGenerator: Failed to generate occupancy grid using max probability");
return null;
}
}
///
/// Merge a single submap into max probability buffer.
/// Supports both ProbabilityGrid and TSDF2D grid types.
/// IMPORTANT: localToMapTransform transforms from LOCAL MAP FRAME to map frame,
/// NOT from submap frame. GetCellCenter() returns local map frame coordinates.
///
private static void MergeSubmapUsingMaxProbability(
Submap2D submap2D,
Rigid3d localToMapTransform,
double[] maxProbability,
int gridWidth,
int gridHeight,
double originX,
double originY,
double resolution)
{
var grid = submap2D.Grid;
if (grid == null)
return;
// Dispatch to appropriate handler based on grid type
if (grid is ProbabilityGrid probabilityGrid)
{
MergeSubmapUsingMaxProbabilityFromProbabilityGrid(
probabilityGrid, localToMapTransform, maxProbability,
gridWidth, gridHeight, originX, originY, resolution);
}
else if (grid is TSDF2D tsdfGrid)
{
MergeSubmapUsingMaxProbabilityFromTSDF(
tsdfGrid, localToMapTransform, maxProbability,
gridWidth, gridHeight, originX, originY, resolution);
}
}
///
/// Merge ProbabilityGrid submap into max probability buffer.
/// IMPORTANT: localToMapTransform transforms from LOCAL MAP FRAME to map frame.
/// GetCellCenter() returns coordinates in local map frame (NOT submap frame).
///
private static void MergeSubmapUsingMaxProbabilityFromProbabilityGrid(
ProbabilityGrid probabilityGrid,
Rigid3d localToMapTransform,
double[] maxProbability,
int gridWidth,
int gridHeight,
double originX,
double originY,
double resolution)
{
var limits = probabilityGrid.Limits;
probabilityGrid.ComputeCroppedLimits(out var croppedOffset, out var croppedLimits);
if (croppedLimits.NumXCells <= 0 || croppedLimits.NumYCells <= 0)
return;
for (int y = 0; y < croppedLimits.NumYCells; y++)
{
for (int x = 0; x < croppedLimits.NumXCells; x++)
{
var cellIndex = new CartographerSharp.Common.Math.Array2i(croppedOffset.X + x, croppedOffset.Y + y);
if (!probabilityGrid.IsKnown(cellIndex))
continue;
// cellCenter is in LOCAL MAP FRAME (because limits.Max is set from LocalPose.Translation)
var cellCenter = limits.GetCellCenter(cellIndex);
// Transform from local map frame to map frame
var mapPoint = localToMapTransform.TransformPoint(new Vector3(cellCenter.X, cellCenter.Y, 0.0));
var gridX = (int)Math.Floor((mapPoint.X - originX) / resolution);
var gridY = (int)Math.Floor((mapPoint.Y - originY) / resolution);
if (gridX >= 0 && gridX < gridWidth && gridY >= 0 && gridY < gridHeight)
{
var probability = probabilityGrid.GetProbability(cellIndex);
var idx = gridY * gridWidth + gridX;
// Take maximum probability (most pessimistic)
if (maxProbability[idx] < 0 || probability > maxProbability[idx])
{
maxProbability[idx] = probability;
}
}
}
}
}
///
/// Merge TSDF2D submap into max probability buffer.
/// Converts TSD to probability: tsd > 0 (free) → low probability, tsd < 0 (occupied) → high probability
/// IMPORTANT: localToMapTransform transforms from LOCAL MAP FRAME to map frame.
/// GetCellCenter() returns coordinates in local map frame (NOT submap frame).
///
private static void MergeSubmapUsingMaxProbabilityFromTSDF(
TSDF2D tsdfGrid,
Rigid3d localToMapTransform,
double[] maxProbability,
int gridWidth,
int gridHeight,
double originX,
double originY,
double resolution)
{
var limits = tsdfGrid.Limits;
tsdfGrid.ComputeCroppedLimits(out var croppedOffset, out var croppedLimits);
if (croppedLimits.NumXCells <= 0 || croppedLimits.NumYCells <= 0)
return;
for (int y = 0; y < croppedLimits.NumYCells; y++)
{
for (int x = 0; x < croppedLimits.NumXCells; x++)
{
var cellIndex = new CartographerSharp.Common.Math.Array2i(croppedOffset.X + x, croppedOffset.Y + y);
if (!tsdfGrid.IsKnown(cellIndex))
continue;
// cellCenter is in LOCAL MAP FRAME (because limits.Max is set from LocalPose.Translation)
var cellCenter = limits.GetCellCenter(cellIndex);
// Transform from local map frame to map frame
var mapPoint = localToMapTransform.TransformPoint(new Vector3(cellCenter.X, cellCenter.Y, 0.0));
var gridX = (int)Math.Floor((mapPoint.X - originX) / resolution);
var gridY = (int)Math.Floor((mapPoint.Y - originY) / resolution);
if (gridX >= 0 && gridX < gridWidth && gridY >= 0 && gridY < gridHeight)
{
// Get TSD and weight from TSDF grid
var (tsd, weight) = tsdfGrid.GetTSDAndWeight(cellIndex);
// Skip cells with very low weight
if (weight < 0.1)
continue;
// Convert TSD to probability
// tsd > 0: Free space → probability LOW
// tsd < 0: Occupied → probability HIGH
const double maxTsd = 0.3; // Typical truncation distance
var normalizedTsd = Math.Clamp(tsd / maxTsd, -1.0, 1.0);
// Convert to probability: tsd=1 → prob=0, tsd=-1 → prob=1
var probability = 0.5 * (1.0 - normalizedTsd);
// Scale by weight for confidence
var normalizedWeight = Math.Min(weight / 10.0, 1.0);
probability = 0.5 + (probability - 0.5) * normalizedWeight;
probability = Math.Clamp(probability, 0.0, 1.0);
var idx = gridY * gridWidth + gridX;
// Take maximum probability (most pessimistic)
if (maxProbability[idx] < 0 || probability > maxProbability[idx])
{
maxProbability[idx] = probability;
}
}
}
}
}
private static void ConvertMaxProbabilityToOccupancy(
double[] maxProbability,
OccupancyGrid occupancyGrid,
OccupancyGridConfiguration? config)
{
var useBinary = config?.UseBinaryOutput ?? true;
var ambiguousLower = config?.AmbiguousRangeLower ?? 0.35;
var ambiguousUpper = config?.AmbiguousRangeUpper ?? 0.65;
var ambiguousValue = config?.AmbiguousCellValue ?? (sbyte)-1;
for (int i = 0; i < maxProbability.Length; i++)
{
if (maxProbability[i] < 0)
{
occupancyGrid.Data[i] = -1;
continue;
}
var probability = maxProbability[i];
var occupancy = (int)Math.Round(probability * 100.0);
if (useBinary)
{
if (probability < ambiguousLower)
occupancyGrid.Data[i] = 0;
else if (probability > ambiguousUpper)
occupancyGrid.Data[i] = 100;
else
occupancyGrid.Data[i] = ambiguousValue;
}
else
{
occupancyGrid.Data[i] = (sbyte)Math.Clamp(occupancy, 0, 100);
}
}
}
#endregion
#region Strategy Dispatcher
///
/// Generate occupancy grid using the configured merge strategy.
/// This is the main entry point that dispatches to the appropriate implementation.
///
public static OccupancyGrid? Generate(
IMapBuilder mapBuilder,
double resolution,
double padding,
ILogger? logger = null,
OccupancyGridConfiguration? config = null)
=> Generate(mapBuilder, resolution, padding, -1, out _, logger, config);
///
/// Generate occupancy grid with version-based change detection.
/// If >= 0 and no new nodes have been
/// inserted since that version, returns null immediately (skips regeneration).
/// receives the PoseGraph version at the time
/// of the snapshot so the caller can cache it for the next call.
///
public static OccupancyGrid? Generate(
IMapBuilder mapBuilder,
double resolution,
double padding,
int lastGeneratedVersion,
out int snapshotVersion,
ILogger? logger = null,
OccupancyGridConfiguration? config = null)
{
snapshotVersion = 0;
var strategy = config?.MergeStrategy ?? SubmapMergeStrategy.LogOddsSum;
// Non-blocking snapshot: try to get submap data without blocking AddSensorData.
// TryGetSubmapSnapshot uses Monitor.TryEnter(0) on PoseGraph._dataLock.
// If the lock is busy (e.g., during optimization or AddNode), we skip this generation
// and the next 2-second cycle will retry.
var poseGraph = mapBuilder.PoseGraph;
MapById? allSubmapData;
Rigid3d transformToMap;
if (poseGraph is CartographerSharp.Mapping.Internal.D2D.PoseGraph2D pg2d)
{
if (!pg2d.TryGetSubmapSnapshot(out allSubmapData, out transformToMap, out snapshotVersion))
{
logger?.LogDebug("OccupancyGridGenerator.Generate: Skipped - PoseGraph lock busy");
return null;
}
// Skip regeneration if no new nodes have been inserted since the last generation.
if (lastGeneratedVersion >= 0 && snapshotVersion == lastGeneratedVersion)
{
logger?.LogDebug(
"OccupancyGridGenerator.Generate: Skipped - no new data (version={Version})",
snapshotVersion);
return null;
}
}
else
{
allSubmapData = poseGraph.GetAllSubmapData();
transformToMap = poseGraph.GetTransformToMap();
}
if (allSubmapData == null)
return null;
int totalSubmaps = 0;
int submap2DCount = 0;
foreach (var idDataRef in allSubmapData)
{
totalSubmaps++;
if (idDataRef.Data.Submap is CartographerSharp.Mapping.D2D.Submap2D)
submap2DCount++;
}
if (submap2DCount == 0)
{
logger?.LogWarning("OccupancyGridGenerator.Generate: No Submap2D found, returning null");
return null;
}
// Pass pre-fetched submap data to strategy methods to avoid double GetAllSubmapData() call
return strategy switch
{
SubmapMergeStrategy.LogOddsSum => GenerateFromMapBuilderUsingLogOdds(
mapBuilder, resolution, padding, logger, config, allSubmapData, transformToMap),
SubmapMergeStrategy.MaxProbability => GenerateFromMapBuilderUsingMaxProbability(
mapBuilder, resolution, padding, logger, config),
SubmapMergeStrategy.PorterDuff => GenerateFromMapBuilderUsingTextures(
mapBuilder, resolution, padding, logger, config),
_ => GenerateFromMapBuilderUsingLogOdds(
mapBuilder, resolution, padding, logger, config, allSubmapData, transformToMap)
};
}
#endregion
}