using RobotNet10.Shared.Localization; using SkiaSharp; namespace RobotNet10.RobotApp.SLAM.Cartographer.Helpers; /// /// Helper class for saving OccupancyGrid to various file formats. /// Consolidates file saving logic used by both CartographerService and MapSaveProcessor. /// public static class OccupancyGridFileHelper { #region Public API /// /// Save all map file formats (PGM, YAML, PNG, JPG) to the specified directory. /// public static async Task SaveAllFormatsAsync( OccupancyGrid occupancyGrid, string mapPath, CancellationToken cancellationToken = default, ILogger? logger = null) { var pgmPath = Path.Combine(mapPath, "map.pgm"); var yamlPath = Path.Combine(mapPath, "map.yaml"); var pngPath = Path.Combine(mapPath, "map.png"); var jpgPath = Path.Combine(mapPath, "map.jpg"); SaveAsPgm(occupancyGrid, pgmPath, logger); SaveAsYaml(occupancyGrid, yamlPath, "map.png", logger); await Task.Run(() => SaveAsPng(occupancyGrid, pngPath, cancellationToken, logger), cancellationToken); await Task.Run(() => SaveAsJpg(occupancyGrid, jpgPath, cancellationToken, logger), cancellationToken); } /// /// Save occupancy grid as PGM file (binary format P5) /// public static void SaveAsPgm(OccupancyGrid occupancyGrid, string pgmPath, ILogger? logger = null) { try { using var fileStream = new FileStream(pgmPath, FileMode.Create, FileAccess.Write); using var writer = new StreamWriter(fileStream); // Write PGM header writer.WriteLine("P5"); // Binary format writer.WriteLine($"{occupancyGrid.Width} {occupancyGrid.Height}"); writer.WriteLine("255"); // Max value // Flush header before writing binary data writer.Flush(); // Write pixel data directly without Y-flip. // Both OccupancyGrid and PGM file use same convention for consistency with PgmLoader. var buffer = new byte[occupancyGrid.Width * occupancyGrid.Height]; for (int y = 0; y < occupancyGrid.Height; y++) { for (int x = 0; x < occupancyGrid.Width; x++) { int index = y * occupancyGrid.Width + x; var occupancyValue = occupancyGrid.Data[index]; if (occupancyValue == -1) { buffer[index] = 205; // Unknown (gray) } else { // Convert occupancy (0-100) to PGM (0-255) // occupancy 0 (free) -> PGM 254 (white) // occupancy 100 (occupied) -> PGM 0 (black) buffer[index] = (byte)(254 - (occupancyValue * 254 / 100)); } } } fileStream.Write(buffer, 0, buffer.Length); fileStream.Flush(); } catch (Exception ex) { logger?.LogError(ex, "OccupancyGridFileHelper: Failed to save PGM file: {Path}", pgmPath); throw; } } /// /// Save occupancy grid as YAML file (ROS map format) /// public static void SaveAsYaml(OccupancyGrid occupancyGrid, string yamlPath, string imageFilename, ILogger? logger = null) { try { var originX = occupancyGrid.Origin.Position.X; var originY = occupancyGrid.Origin.Position.Y; // Write YAML file (ROS map format) var yamlContent = $"image: {imageFilename}\n" + $"resolution: {occupancyGrid.Resolution:F10}\n" + $"origin: [{originX:F10}, {originY:F10}, 0.0]\n" + $"negate: 0\n" + $"occupied_thresh: 0.65\n" + $"free_thresh: 0.196\n"; File.WriteAllText(yamlPath, yamlContent); } catch (Exception ex) { logger?.LogError(ex, "OccupancyGridFileHelper: Failed to save YAML file: {Path}", yamlPath); throw; } } /// /// Save occupancy grid as PNG file /// public static void SaveAsPng(OccupancyGrid occupancyGrid, string pngPath, CancellationToken cancellationToken = default, ILogger? logger = null) { RenderAndSaveImage(occupancyGrid, pngPath, SKEncodedImageFormat.Png, 100, cancellationToken, logger); } /// /// Save occupancy grid as JPG file /// public static void SaveAsJpg(OccupancyGrid occupancyGrid, string jpgPath, CancellationToken cancellationToken = default, ILogger? logger = null) { RenderAndSaveImage(occupancyGrid, jpgPath, SKEncodedImageFormat.Jpeg, 95, cancellationToken, logger); } #endregion #region Image Rendering /// /// Render occupancy grid to an image and save in the specified format. /// Shared implementation for both PNG and JPG output. /// private static void RenderAndSaveImage( OccupancyGrid occupancyGrid, string outputPath, SKEncodedImageFormat format, int quality, CancellationToken cancellationToken, ILogger? logger) { var formatName = format == SKEncodedImageFormat.Png ? "PNG" : "JPG"; try { if (occupancyGrid == null || occupancyGrid.Width <= 0 || occupancyGrid.Height <= 0) { logger?.LogWarning("OccupancyGridFileHelper: Cannot save {Format} - invalid occupancy grid", formatName); return; } cancellationToken.ThrowIfCancellationRequested(); var width = occupancyGrid.Width; var height = occupancyGrid.Height; // Create SKBitmap with RGBA_8888 format using var bitmap = new SKBitmap(width, height, SKColorType.Rgba8888, SKAlphaType.Opaque); // Get pixel buffer pointer for direct memory access var pixelsPtr = bitmap.GetPixels(); if (pixelsPtr == IntPtr.Zero) { throw new InvalidOperationException("Failed to get pixel buffer from bitmap"); } // Convert occupancy grid data to image pixels // Flip Y: OccupancyGrid uses ROS convention (row 0 = world bottom, Y up) // but PNG/JPG image convention is row 0 = top, Y down. unsafe { var pixels = (byte*)pixelsPtr.ToPointer(); for (int y = 0; y < height; y++) { for (int x = 0; x < width; x++) { // Read from flipped Y in grid (bottom-up) to write top-down in image var srcIndex = (height - 1 - y) * width + x; var occupancyValue = occupancyGrid.Data[srcIndex]; byte intensity; if (occupancyValue == -1) { intensity = 205; // Unknown (gray) } else if (occupancyValue == 0) { intensity = 254; // Free space (white) } else { // Occupied space: Convert occupancy (0-100) to pixel intensity (0-255) var intensityValue = 254.0 - (occupancyValue * 254.0 / 100.0); intensity = (byte)Math.Clamp((int)Math.Round(intensityValue), 0, 254); } // Write RGBA bytes directly (destination index uses image row y) var dstIndex = y * width + x; var pixelOffset = dstIndex * 4; pixels[pixelOffset] = intensity; // R pixels[pixelOffset + 1] = intensity; // G pixels[pixelOffset + 2] = intensity; // B pixels[pixelOffset + 3] = 255; // A (opaque) } } } // Encode and save using var image = SKImage.FromBitmap(bitmap) ?? throw new InvalidOperationException("Failed to create SKImage from bitmap"); using var data = image.Encode(format, quality) ?? throw new InvalidOperationException($"Failed to encode {formatName} image"); var directory = Path.GetDirectoryName(outputPath); if (!string.IsNullOrEmpty(directory)) { Directory.CreateDirectory(directory); } if (File.Exists(outputPath)) { File.Delete(outputPath); } using var stream = File.Create(outputPath); data.SaveTo(stream); stream.Flush(); } catch (OperationCanceledException) { logger?.LogWarning("OccupancyGridFileHelper: {Format} save cancelled: {Path}", formatName, outputPath); throw; } catch (Exception ex) { logger?.LogError(ex, "OccupancyGridFileHelper: Failed to save {Format} file: {Path}", formatName, outputPath); // Don't throw - image files are optional } } #endregion }