route stitching

This commit is contained in:
Anton Martynenko
2025-11-01 16:54:46 +01:00
parent 8714a4817d
commit 11395ec913
15 changed files with 698 additions and 10 deletions
@@ -0,0 +1,424 @@
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using SatelliteProvider.Common.Configs;
using SatelliteProvider.Common.Utils;
using SatelliteProvider.DataAccess.Repositories;
using SixLabors.ImageSharp;
using SixLabors.ImageSharp.PixelFormats;
using SixLabors.ImageSharp.Processing;
namespace SatelliteProvider.Services;
public class RouteProcessingService : BackgroundService
{
private readonly IRouteRepository _routeRepository;
private readonly IRegionRepository _regionRepository;
private readonly StorageConfig _storageConfig;
private readonly ILogger<RouteProcessingService> _logger;
private readonly TimeSpan _checkInterval = TimeSpan.FromSeconds(10);
public RouteProcessingService(
IRouteRepository routeRepository,
IRegionRepository regionRepository,
IOptions<StorageConfig> storageConfig,
ILogger<RouteProcessingService> logger)
{
_routeRepository = routeRepository;
_regionRepository = regionRepository;
_storageConfig = storageConfig.Value;
_logger = logger;
}
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
_logger.LogInformation("Route Processing Service started");
while (!stoppingToken.IsCancellationRequested)
{
try
{
await ProcessPendingRoutesAsync(stoppingToken);
}
catch (Exception ex)
{
_logger.LogError(ex, "Error in route processing service");
}
await Task.Delay(_checkInterval, stoppingToken);
}
_logger.LogInformation("Route Processing Service stopped");
}
private async Task ProcessPendingRoutesAsync(CancellationToken cancellationToken)
{
var pendingRoutes = await GetRoutesWithPendingMapsAsync();
foreach (var route in pendingRoutes)
{
if (cancellationToken.IsCancellationRequested)
break;
try
{
await ProcessRouteIfReadyAsync(route.Id, cancellationToken);
}
catch (Exception ex)
{
_logger.LogError(ex, "Error processing route {RouteId}", route.Id);
}
}
}
private async Task<List<(Guid Id, bool RequestMaps)>> GetRoutesWithPendingMapsAsync()
{
var routes = await _routeRepository.GetRoutesWithPendingMapsAsync();
return routes.Select(r => (r.Id, r.RequestMaps)).ToList();
}
private async Task ProcessRouteIfReadyAsync(Guid routeId, CancellationToken cancellationToken)
{
var route = await _routeRepository.GetByIdAsync(routeId);
if (route == null || !route.RequestMaps || route.MapsReady)
{
return;
}
var regionIds = await _routeRepository.GetRegionIdsByRouteAsync(routeId);
if (!regionIds.Any())
{
_logger.LogWarning("Route {RouteId} has no regions linked", routeId);
return;
}
var allCompleted = true;
var anyFailed = false;
foreach (var regionId in regionIds)
{
var region = await _regionRepository.GetByIdAsync(regionId);
if (region == null)
{
_logger.LogWarning("Region {RegionId} not found for route {RouteId}", regionId, routeId);
continue;
}
if (region.Status == "failed")
{
anyFailed = true;
}
else if (region.Status != "completed")
{
allCompleted = false;
}
}
if (!allCompleted)
{
return;
}
if (anyFailed)
{
_logger.LogWarning("Route {RouteId} has failed regions, skipping processing", routeId);
return;
}
_logger.LogInformation("All regions completed for route {RouteId}, starting final processing", routeId);
await GenerateRouteMapsAsync(routeId, route, regionIds, cancellationToken);
}
private async Task GenerateRouteMapsAsync(
Guid routeId,
DataAccess.Models.RouteEntity route,
IEnumerable<Guid> regionIds,
CancellationToken cancellationToken)
{
try
{
var readyDir = _storageConfig.ReadyDirectory;
Directory.CreateDirectory(readyDir);
var allTiles = new Dictionary<string, TileInfo>();
int totalTilesFromRegions = 0;
int duplicateTiles = 0;
foreach (var regionId in regionIds)
{
var region = await _regionRepository.GetByIdAsync(regionId);
if (region == null || string.IsNullOrEmpty(region.CsvFilePath) || !File.Exists(region.CsvFilePath))
{
_logger.LogWarning("Region {RegionId} CSV not found for route {RouteId}", regionId, routeId);
continue;
}
var csvLines = await File.ReadAllLinesAsync(region.CsvFilePath, cancellationToken);
foreach (var line in csvLines.Skip(1))
{
var parts = line.Split(',');
if (parts.Length < 3) continue;
if (!double.TryParse(parts[0], out var lat)) continue;
if (!double.TryParse(parts[1], out var lon)) continue;
var filePath = parts[2];
totalTilesFromRegions++;
var key = $"{lat:F6}_{lon:F6}";
if (!allTiles.ContainsKey(key))
{
allTiles[key] = new TileInfo
{
Latitude = lat,
Longitude = lon,
FilePath = filePath
};
}
else
{
duplicateTiles++;
}
}
}
_logger.LogInformation("Route {RouteId}: Collected {UniqueCount} unique tiles ({DuplicateCount} duplicates from {TotalCount} total)",
routeId, allTiles.Count, duplicateTiles, totalTilesFromRegions);
var csvPath = Path.Combine(readyDir, $"route_{routeId}_ready.csv");
await GenerateRouteCsvAsync(csvPath, allTiles.Values, cancellationToken);
string? stitchedImagePath = null;
if (route.RequestMaps)
{
stitchedImagePath = Path.Combine(readyDir, $"route_{routeId}_stitched.jpg");
await StitchRouteTilesAsync(allTiles.Values.ToList(), stitchedImagePath, route.ZoomLevel, cancellationToken);
}
var summaryPath = Path.Combine(readyDir, $"route_{routeId}_summary.txt");
await GenerateRouteSummaryAsync(summaryPath, route, allTiles.Count, totalTilesFromRegions, duplicateTiles, cancellationToken);
route.MapsReady = true;
route.CsvFilePath = csvPath;
route.SummaryFilePath = summaryPath;
route.StitchedImagePath = stitchedImagePath;
route.UpdatedAt = DateTime.UtcNow;
await _routeRepository.UpdateRouteAsync(route);
_logger.LogInformation("Route {RouteId} maps processing completed successfully", routeId);
}
catch (Exception ex)
{
_logger.LogError(ex, "Error generating maps for route {RouteId}", routeId);
throw;
}
}
private async Task GenerateRouteCsvAsync(
string filePath,
IEnumerable<TileInfo> tiles,
CancellationToken cancellationToken)
{
var orderedTiles = tiles.OrderByDescending(t => t.Latitude).ThenBy(t => t.Longitude).ToList();
using var writer = new StreamWriter(filePath);
await writer.WriteLineAsync("latitude,longitude,file_path");
foreach (var tile in orderedTiles)
{
await writer.WriteLineAsync($"{tile.Latitude:F6},{tile.Longitude:F6},{tile.FilePath}");
}
_logger.LogInformation("Route CSV generated: {FilePath} with {Count} tiles", filePath, orderedTiles.Count);
}
private async Task GenerateRouteSummaryAsync(
string filePath,
DataAccess.Models.RouteEntity route,
int uniqueTiles,
int totalTilesFromRegions,
int duplicateTiles,
CancellationToken cancellationToken)
{
var summary = new System.Text.StringBuilder();
summary.AppendLine("Route Maps Summary");
summary.AppendLine("==================");
summary.AppendLine($"Route ID: {route.Id}");
summary.AppendLine($"Route Name: {route.Name}");
if (!string.IsNullOrEmpty(route.Description))
{
summary.AppendLine($"Description: {route.Description}");
}
summary.AppendLine($"Total Points: {route.TotalPoints}");
summary.AppendLine($"Total Distance: {route.TotalDistanceMeters:F2} meters");
summary.AppendLine($"Region Size: {route.RegionSizeMeters:F0} meters");
summary.AppendLine($"Zoom Level: {route.ZoomLevel}");
summary.AppendLine();
summary.AppendLine("Tile Statistics:");
summary.AppendLine($"- Unique Tiles: {uniqueTiles}");
summary.AppendLine($"- Total Tiles from Regions: {totalTilesFromRegions}");
summary.AppendLine($"- Duplicate Tiles (overlap): {duplicateTiles}");
summary.AppendLine();
summary.AppendLine("Files Created:");
summary.AppendLine($"- CSV: route_{route.Id}_ready.csv");
summary.AppendLine($"- Summary: route_{route.Id}_summary.txt");
if (route.RequestMaps)
{
summary.AppendLine($"- Stitched Map: route_{route.Id}_stitched.jpg");
}
summary.AppendLine();
summary.AppendLine($"Completed: {DateTime.UtcNow:yyyy-MM-dd HH:mm:ss} UTC");
await File.WriteAllTextAsync(filePath, summary.ToString(), cancellationToken);
_logger.LogInformation("Route summary generated: {FilePath}", filePath);
}
private async Task StitchRouteTilesAsync(
List<TileInfo> tiles,
string outputPath,
int zoomLevel,
CancellationToken cancellationToken)
{
if (tiles.Count == 0)
{
_logger.LogWarning("No tiles to stitch for route map");
return;
}
const int tileSizePixels = 256;
var tileCoords = tiles.Select(t =>
{
var (tileX, tileY) = ExtractTileCoordinatesFromFilename(t.FilePath);
return new
{
t.Latitude,
t.Longitude,
t.FilePath,
TileX = tileX,
TileY = tileY
};
}).Where(t => t.TileX >= 0 && t.TileY >= 0).ToList();
var minX = tileCoords.Min(t => t.TileX);
var maxX = tileCoords.Max(t => t.TileX);
var minY = tileCoords.Min(t => t.TileY);
var maxY = tileCoords.Max(t => t.TileY);
var gridWidth = maxX - minX + 1;
var gridHeight = maxY - minY + 1;
var imageWidth = gridWidth * tileSizePixels;
var imageHeight = gridHeight * tileSizePixels;
_logger.LogInformation("Stitching route map: {Width}x{Height} pixels (grid: {GridWidth}x{GridHeight} tiles)",
imageWidth, imageHeight, gridWidth, gridHeight);
_logger.LogInformation("Bounding box: top={MinY}, left={MinX}, bottom={MaxY}, right={MaxX}",
minY, minX, maxY, maxX);
using var stitchedImage = new Image<Rgb24>(imageWidth, imageHeight);
stitchedImage.Mutate(ctx => ctx.BackgroundColor(Color.Black));
var uniqueTileCoords = tileCoords
.GroupBy(t => $"{t.TileX}_{t.TileY}")
.Select(g => g.First())
.OrderBy(t => t.TileY)
.ThenBy(t => t.TileX)
.ToList();
_logger.LogInformation("Unique tiles to place: {Count}", uniqueTileCoords.Count);
_logger.LogInformation("Sample tiles (first 5):");
foreach (var sample in uniqueTileCoords.Take(5))
{
_logger.LogInformation(" Tile ({TileX}, {TileY}) from ({Lat:F6}, {Lon:F6})",
sample.TileX, sample.TileY, sample.Latitude, sample.Longitude);
}
int placedTiles = 0;
int missingTiles = 0;
foreach (var tile in uniqueTileCoords)
{
var destX = (tile.TileX - minX) * tileSizePixels;
var destY = (tile.TileY - minY) * tileSizePixels;
if (File.Exists(tile.FilePath))
{
try
{
using var tileImage = await Image.LoadAsync<Rgb24>(tile.FilePath, cancellationToken);
if (tileImage.Width != tileSizePixels || tileImage.Height != tileSizePixels)
{
_logger.LogWarning("Tile {FilePath} has wrong size {Width}x{Height}, expected {Expected}x{Expected}",
tile.FilePath, tileImage.Width, tileImage.Height, tileSizePixels);
}
stitchedImage.Mutate(ctx => ctx.DrawImage(tileImage, new Point(destX, destY), 1f));
placedTiles++;
if (placedTiles <= 3)
{
_logger.LogInformation("Placed tile {Count}: ({TileX},{TileY}) at pixel ({DestX},{DestY})",
placedTiles, tile.TileX, tile.TileY, destX, destY);
}
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Failed to load tile at {FilePath}, leaving black", tile.FilePath);
missingTiles++;
}
}
else
{
_logger.LogWarning("Tile file not found: {FilePath}, leaving black", tile.FilePath);
missingTiles++;
}
}
await stitchedImage.SaveAsJpegAsync(outputPath, cancellationToken);
var totalPossibleTiles = gridWidth * gridHeight;
var uncoveredTiles = totalPossibleTiles - placedTiles - missingTiles;
_logger.LogInformation("Route map stitched: {OutputPath}", outputPath);
_logger.LogInformation(" Tiles placed: {PlacedTiles}", placedTiles);
_logger.LogInformation(" Tiles missing (file issues): {MissingTiles}", missingTiles);
_logger.LogInformation(" Uncovered area (black): {UncoveredTiles} tiles", uncoveredTiles);
_logger.LogInformation(" Total canvas: {TotalTiles} tiles ({GridWidth}x{GridHeight})",
totalPossibleTiles, gridWidth, gridHeight);
}
private static (int TileX, int TileY) ExtractTileCoordinatesFromFilename(string filePath)
{
try
{
var filename = Path.GetFileNameWithoutExtension(filePath);
var parts = filename.Split('_');
if (parts.Length >= 4 && parts[0] == "tile")
{
if (int.TryParse(parts[2], out var tileX) && int.TryParse(parts[3], out var tileY))
{
return (tileX, tileY);
}
}
}
catch
{
}
return (-1, -1);
}
}
public class TileInfo
{
public double Latitude { get; set; }
public double Longitude { get; set; }
public string FilePath { get; set; } = string.Empty;
}