/* * Copyright 2017 The Cartographer Authors * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ using CartographerSharp.Mapping; using CartographerSharp.Mapping.D2D; using CartographerSharp.Transform; using RobotNet10.Shared.Numbers; using System.IO.Compression; using System.Runtime.CompilerServices; // Use aliases to avoid ambiguity between Mapping.D2D.Submap2D and Models.Mapping.Submap2D using Submap2DClass = CartographerSharp.Mapping.D2D.Submap2D; using SubmapQueryModel = CartographerSharp.Models.Mapping.SubmapQuery; namespace CartographerSharp.IO; /// /// Represents unpacked texture pixel data. /// Match C++: SubmapTexture::Pixels /// public readonly struct SubmapTexturePixels { public readonly byte[] Intensity; public readonly byte[] Alpha; public SubmapTexturePixels(byte[] intensity, byte[] alpha) { Intensity = intensity; Alpha = alpha; } } /// /// Represents a submap slice ready for painting. /// Match C++: SubmapSlice /// public class SubmapSlice { // Texture data public int Width { get; set; } public int Height { get; set; } public int Version { get; set; } public double Resolution { get; set; } public Rigid3d SlicePose { get; set; } // Pixel data (ARGB format, uint32 per pixel) public uint[]? PixelData { get; set; } // Metadata public Rigid3d Pose { get; set; } public int MetadataVersion { get; set; } = -1; } /// /// Result of painting submap slices. /// Match C++: PaintSubmapSlicesResult /// public class PaintSubmapSlicesResult { /// /// Pixel data in ARGB format (row-major, top-to-bottom). /// public uint[] PixelData { get; } /// /// Width of the result image in pixels. /// public int Width { get; } /// /// Height of the result image in pixels. /// public int Height { get; } /// /// Top-left pixel of 'surface' in map frame (world coordinates). /// public Vector2 Origin { get; } public PaintSubmapSlicesResult(uint[] pixelData, int width, int height, Vector2 origin) { PixelData = pixelData; Width = width; Height = height; Origin = origin; } } /// /// Submap painting utilities for generating occupancy grids from submap textures. /// Enhanced implementation matching Cairo Graphics Library quality: /// - Bilinear interpolation for smooth sampling /// - Porter-Duff Source-Over compositing for proper alpha blending /// - Inverse mapping for sub-pixel accuracy (no holes) /// - Affine transformation matrix support /// public static class SubmapPainter { private const int kPaddingPixel = 5; /// /// Unpacks cell data as provided by DrawToSubmapTexture into intensity and alpha arrays. /// Match C++: UnpackTextureData /// /// GZip compressed cells data (value + alpha pairs) /// Texture width /// Texture height /// Unpacked intensity and alpha arrays public static SubmapTexturePixels UnpackTextureData(IList compressedCells, int width, int height) { // Decompress GZip data byte[] cells; using (var compressedStream = new MemoryStream(compressedCells.ToArray())) using (var gzipStream = new GZipStream(compressedStream, CompressionMode.Decompress)) using (var resultStream = new MemoryStream()) { gzipStream.CopyTo(resultStream); cells = resultStream.ToArray(); } var numPixels = width * height; if (cells.Length != 2 * numPixels) { throw new ArgumentException( $"Decompressed cells size mismatch: expected {2 * numPixels}, got {cells.Length}"); } var intensity = new byte[numPixels]; var alpha = new byte[numPixels]; // Match C++: cells[(i * width + j) * 2] for intensity, +1 for alpha for (int i = 0; i < height; i++) { for (int j = 0; j < width; j++) { var index = i * width + j; intensity[index] = cells[index * 2]; alpha[index] = cells[index * 2 + 1]; } } return new SubmapTexturePixels(intensity, alpha); } /// /// Creates pixel data from intensity and alpha arrays. /// Match C++: DrawTexture (without Cairo, using raw pixel arrays) /// /// Intensity values /// Alpha values /// Texture width /// Texture height /// ARGB pixel data (uint32 per pixel) public static uint[] DrawTexture(byte[] intensity, byte[] alpha, int width, int height) { var pixelData = new uint[width * height]; for (int i = 0; i < intensity.Length; i++) { var intensityValue = intensity[i]; var alphaValue = alpha[i]; // Match C++: We use the red channel to track intensity information. // The green channel we use to track if a cell was ever observed. byte observed = (intensityValue == 0 && alphaValue == 0) ? (byte)0 : (byte)255; // ARGB format: (alpha << 24) | (red << 16) | (green << 8) | blue // Match C++: (alpha_value << 24) | (intensity_value << 16) | (observed << 8) | 0 pixelData[i] = ((uint)alphaValue << 24) | ((uint)intensityValue << 16) | ((uint)observed << 8) | 0; } return pixelData; } /// /// Fills a SubmapSlice from a Submap2D. /// Match C++: Part of FillSubmapSlice functionality /// public static SubmapSlice CreateSubmapSlice(Submap2DClass submap, Rigid3d globalPose) { var slice = new SubmapSlice { Pose = globalPose, MetadataVersion = submap.NumRangeData }; var grid = submap.Grid; if (grid == null) { return slice; } // Get texture from grid SubmapQueryModel.Texture texture; if (grid is ProbabilityGrid probabilityGrid) { texture = probabilityGrid.DrawToSubmapTexture(submap.LocalPose); } else if (grid is TSDF2D tsdf2D) { texture = tsdf2D.DrawToSubmapTexture(submap.LocalPose); } else { throw new NotSupportedException($"Unsupported grid type: {grid.GetType().Name}"); } // Unpack texture data var pixels = UnpackTextureData(texture.Cells, texture.Width, texture.Height); slice.Width = texture.Width; slice.Height = texture.Height; slice.Resolution = texture.Resolution; slice.SlicePose = texture.SlicePose; slice.Version = submap.NumRangeData; // Draw texture to pixel data slice.PixelData = DrawTexture(pixels.Intensity, pixels.Alpha, texture.Width, texture.Height); return slice; } /// /// Paints all submap slices into a single image using Cairo-style rendering: /// - Inverse mapping for sub-pixel accuracy /// - Bilinear interpolation for smooth sampling /// - Porter-Duff Source-Over compositing /// Match C++: PaintSubmapSlices /// /// Dictionary of submap slices keyed by SubmapId /// Output resolution in meters per pixel /// Combined image result with pixel data and origin public static PaintSubmapSlicesResult? PaintSubmapSlices( Dictionary submapSlices, double resolution) { if (submapSlices.Count == 0) { return null; } // First pass: compute bounding box using all corner transforms double minX = double.MaxValue, minY = double.MaxValue; double maxX = double.MinValue, maxY = double.MinValue; foreach (var (_, slice) in submapSlices) { if (slice.PixelData == null || slice.Width <= 0 || slice.Height <= 0) { continue; } // Transform the four corners of the submap texture to global coordinates var corners = new Vector2[] { new(0, 0), new(slice.Width, 0), new(0, slice.Height), new(slice.Width, slice.Height) }; // Combined transform: globalPose * slicePose var submapTransform = slice.Pose * slice.SlicePose; foreach (var corner in corners) { // Convert pixel coordinates to submap local coordinates // Match C++ Cairo matrix: cairo_matrix_init(&matrix, homo(1,0), homo(0,0), // -homo(1,1), -homo(0,1), homo(0,3), -homo(1,3)) // In Cartographer's grid convention: // x-index (column) corresponds to world Y axis (decreasing) // y-index (row) corresponds to world X axis (decreasing) // So pixel (col, row) maps to local (-row * res, -col * res) + slice_pose translation var localPoint = new Vector3( -corner.Y * slice.Resolution, -corner.X * slice.Resolution, 0); // Transform to global coordinates var globalPoint = submapTransform.TransformPoint(localPoint); // Update bounding box // Match C++: cairo uses (x, -y) convention for map coordinates var mapX = globalPoint.X / resolution; var mapY = -globalPoint.Y / resolution; minX = Math.Min(minX, mapX); minY = Math.Min(minY, mapY); maxX = Math.Max(maxX, mapX); maxY = Math.Max(maxY, mapY); } } if (minX >= maxX || minY >= maxY) { return null; } // Calculate output size with padding var width = (int)Math.Ceiling(maxX - minX) + 2 * kPaddingPixel; var height = (int)Math.Ceiling(maxY - minY) + 2 * kPaddingPixel; // Origin offset (translation to apply to bring min corner to (padding, padding)) var originX = -minX + kPaddingPixel; var originY = -minY + kPaddingPixel; // Create output pixel buffer // Match C++: cairo_set_source_rgba(cr.get(), 0.5, 0.0, 0.0, 1.); - dark red background // For occupancy grid: observed=0 indicates unknown var outputPixels = new uint[width * height]; // Initialize to unknown (gray color, observed=0) for (int i = 0; i < outputPixels.Length; i++) { outputPixels[i] = 0xFF800000; // Alpha=255, Red=128 (gray), Green=0 (not observed), Blue=0 } // Second pass: paint each submap slice using inverse mapping + bilinear interpolation foreach (var (_, slice) in submapSlices) { if (slice.PixelData == null || slice.Width <= 0 || slice.Height <= 0) { continue; } PaintSubmapSliceCairoStyle(slice, resolution, originX, originY, outputPixels, width, height); } // Calculate the origin in world coordinates var worldOrigin = new Vector2( (minX - kPaddingPixel) * resolution, -(minY - kPaddingPixel) * resolution); return new PaintSubmapSlicesResult(outputPixels, width, height, worldOrigin); } /// /// Paints a single submap slice using Cairo-style rendering: /// - Inverse mapping: for each output pixel, compute source position /// - Bilinear interpolation: sample from 4 neighboring pixels /// - Porter-Duff Source-Over: proper alpha compositing /// private static void PaintSubmapSliceCairoStyle( SubmapSlice slice, double resolution, double originX, double originY, uint[] outputPixels, int outputWidth, int outputHeight) { if (slice.PixelData == null) { return; } // Build affine transformation matrix (Cairo style) // Transform chain: output_pixel -> world -> submap_local -> submap_pixel var submapTransform = slice.Pose * slice.SlicePose; var inverseTransform = submapTransform.Inverse(); // Pre-compute scale factors var outputToWorld = resolution; var worldToSubmap = 1.0 / slice.Resolution; // Compute the bounding box of this slice in output coordinates // to avoid iterating over the entire output var sliceCorners = new Vector2[] { new(0, 0), new(slice.Width, 0), new(0, slice.Height), new(slice.Width, slice.Height) }; int outMinX = outputWidth, outMinY = outputHeight; int outMaxX = 0, outMaxY = 0; foreach (var corner in sliceCorners) { // Match C++ Cairo matrix pixel-to-local mapping var localPoint = new Vector3(-corner.Y * slice.Resolution, -corner.X * slice.Resolution, 0); var globalPoint = submapTransform.TransformPoint(localPoint); var outX = (int)Math.Floor(globalPoint.X / resolution + originX); var outY = (int)Math.Floor(-globalPoint.Y / resolution + originY); outMinX = Math.Min(outMinX, outX - 2); outMinY = Math.Min(outMinY, outY - 2); outMaxX = Math.Max(outMaxX, outX + 2); outMaxY = Math.Max(outMaxY, outY + 2); } // Clamp to output bounds outMinX = Math.Max(0, outMinX); outMinY = Math.Max(0, outMinY); outMaxX = Math.Min(outputWidth - 1, outMaxX); outMaxY = Math.Min(outputHeight - 1, outMaxY); // Inverse mapping: for each output pixel in the bounding box for (int outY = outMinY; outY <= outMaxY; outY++) { for (int outX = outMinX; outX <= outMaxX; outX++) { // Convert output pixel to world coordinates // Match C++ cairo convention: output uses (x, -y) var worldX = (outX - originX) * outputToWorld; var worldY = -(outY - originY) * outputToWorld; // Transform world to submap local coordinates var worldPoint = new Vector3(worldX, worldY, 0); var submapLocalPoint = inverseTransform.TransformPoint(worldPoint); // Convert submap local to pixel coordinates // Inverse of the forward mapping: local = (-row * res, -col * res) // So: col = -local.Y / res, row = -local.X / res var srcX = -submapLocalPoint.Y * worldToSubmap; var srcY = -submapLocalPoint.X * worldToSubmap; // Check if within source bounds (with margin for bilinear) if (srcX < 0 || srcX >= slice.Width - 1 || srcY < 0 || srcY >= slice.Height - 1) { continue; } // Bilinear interpolation var sampledPixel = SampleBilinear(slice.PixelData, slice.Width, slice.Height, srcX, srcY); // Skip if not observed var srcObserved = (sampledPixel >> 8) & 0xFF; if (srcObserved == 0) { continue; } // Porter-Duff Source-Over compositing var dstIndex = outY * outputWidth + outX; var dstPixel = outputPixels[dstIndex]; outputPixels[dstIndex] = BlendSourceOver(sampledPixel, dstPixel); } } } /// /// Bilinear interpolation sampling from a pixel array. /// Returns interpolated ARGB pixel value. /// [MethodImpl(MethodImplOptions.AggressiveInlining)] private static uint SampleBilinear(uint[] pixels, int width, int height, double x, double y) { // Get integer and fractional parts int x0 = (int)Math.Floor(x); int y0 = (int)Math.Floor(y); int x1 = Math.Min(x0 + 1, width - 1); int y1 = Math.Min(y0 + 1, height - 1); double fx = x - x0; double fy = y - y0; // Get four neighboring pixels var p00 = pixels[y0 * width + x0]; var p10 = pixels[y0 * width + x1]; var p01 = pixels[y1 * width + x0]; var p11 = pixels[y1 * width + x1]; // Check if all neighbors are observed (optimization: skip interpolation if any is unknown) var obs00 = (p00 >> 8) & 0xFF; var obs10 = (p10 >> 8) & 0xFF; var obs01 = (p01 >> 8) & 0xFF; var obs11 = (p11 >> 8) & 0xFF; // If any corner is unobserved, use nearest neighbor with observed pixel if (obs00 == 0 || obs10 == 0 || obs01 == 0 || obs11 == 0) { // Find the nearest observed pixel var nearestX = fx < 0.5 ? x0 : x1; var nearestY = fy < 0.5 ? y0 : y1; var nearest = pixels[nearestY * width + nearestX]; if (((nearest >> 8) & 0xFF) != 0) { return nearest; } // Try other corners if (obs00 != 0) return p00; if (obs10 != 0) return p10; if (obs01 != 0) return p01; if (obs11 != 0) return p11; return 0; // All unobserved } // Bilinear interpolation weights double w00 = (1 - fx) * (1 - fy); double w10 = fx * (1 - fy); double w01 = (1 - fx) * fy; double w11 = fx * fy; // Interpolate each channel var a = (uint)Math.Round( ((p00 >> 24) & 0xFF) * w00 + ((p10 >> 24) & 0xFF) * w10 + ((p01 >> 24) & 0xFF) * w01 + ((p11 >> 24) & 0xFF) * w11); var r = (uint)Math.Round( ((p00 >> 16) & 0xFF) * w00 + ((p10 >> 16) & 0xFF) * w10 + ((p01 >> 16) & 0xFF) * w01 + ((p11 >> 16) & 0xFF) * w11); var g = (uint)Math.Round( ((p00 >> 8) & 0xFF) * w00 + ((p10 >> 8) & 0xFF) * w10 + ((p01 >> 8) & 0xFF) * w01 + ((p11 >> 8) & 0xFF) * w11); var b = (uint)Math.Round( (p00 & 0xFF) * w00 + (p10 & 0xFF) * w10 + (p01 & 0xFF) * w01 + (p11 & 0xFF) * w11); return (Math.Min(255u, a) << 24) | (Math.Min(255u, r) << 16) | (Math.Min(255u, g) << 8) | Math.Min(255u, b); } /// /// Porter-Duff Source-Over compositing matching Cairo's OVER operator. /// /// Cairo uses premultiplied alpha OVER: result = src + dst * (1 - srcA/255) /// /// This naturally produces the correct behavior for occupancy grids: /// - Free cells (srcA=0): additive blending (factor=1.0) → R gets brighter with more observations /// - Occupied cells (srcA>0): standard OVER → R gets darker with more observations /// - Multiple free observations → brighter (lower occupancy = more confident free space) /// - Multiple occupied observations → darker (higher occupancy = more confident wall) /// [MethodImpl(MethodImplOptions.AggressiveInlining)] private static uint BlendSourceOver(uint src, uint dst) { // Extract channels // Format: (alpha << 24) | (intensity/red << 16) | (observed/green << 8) | blue var srcA = (src >> 24) & 0xFF; var srcR = (src >> 16) & 0xFF; var srcG = (src >> 8) & 0xFF; var srcB = src & 0xFF; var dstA = (dst >> 24) & 0xFF; var dstR = (dst >> 16) & 0xFF; var dstG = (dst >> 8) & 0xFF; var dstB = dst & 0xFF; // Cairo OVER operator (premultiplied alpha): // result = src + dst * (1 - srcA / 255) var factor = 1.0 - srcA / 255.0; var outA = (uint)Math.Min(255, (int)Math.Round(srcA + dstA * factor)); var outR = (uint)Math.Min(255, (int)Math.Round(srcR + dstR * factor)); var outG = (uint)Math.Min(255, (int)Math.Round(srcG + dstG * factor)); var outB = (uint)Math.Min(255, (int)Math.Round(srcB + dstB * factor)); return (outA << 24) | (outR << 16) | (outG << 8) | outB; } /// /// Converts painted result to occupancy grid values. /// Returns array of occupancy values: 0 = free, 100 = occupied, -1 = unknown. /// Match C++: CreateOccupancyGrid in data_conversion.cc /// /// Paint result from PaintSubmapSlices /// Array of sbyte occupancy values public static sbyte[] ConvertToOccupancyValues(PaintSubmapSlicesResult result) { var occupancyValues = new sbyte[result.Width * result.Height]; for (int i = 0; i < result.PixelData.Length; i++) { var pixel = result.PixelData[i]; // Match C++ pixel format: (alpha << 24) | (intensity/color << 16) | (observed << 8) | 0 var color = (pixel >> 16) & 0xFF; // RED channel = intensity/color var observed = (pixel >> 8) & 0xFF; // GREEN channel = observed flag if (observed == 0) { // Unknown cell - not observed occupancyValues[i] = -1; } else { // Match C++ formula from data_conversion.cc line 386-389: // const int value = observed == 0 // ? -1 // : ::cartographer::common::RoundToInt((1. - color / 255.) * 100.); // // color = 0 (black) → occupancy = 100 (occupied) // color = 255 (white) → occupancy = 0 (free) var occupancy = (int)Math.Round((1.0 - color / 255.0) * 100.0); occupancyValues[i] = (sbyte)Math.Clamp(occupancy, 0, 100); } } return occupancyValues; } }