mirror of
https://github.com/azaion/satellite-provider.git
synced 2026-04-22 06:56:37 +00:00
767 lines
34 KiB
C#
767 lines
34 KiB
C#
using Microsoft.Extensions.DependencyInjection;
|
|
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 IServiceProvider _serviceProvider;
|
|
private readonly StorageConfig _storageConfig;
|
|
private readonly ILogger<RouteProcessingService> _logger;
|
|
private readonly TimeSpan _checkInterval = TimeSpan.FromSeconds(5);
|
|
|
|
public RouteProcessingService(
|
|
IRouteRepository routeRepository,
|
|
IRegionRepository regionRepository,
|
|
IServiceProvider serviceProvider,
|
|
IOptions<StorageConfig> storageConfig,
|
|
ILogger<RouteProcessingService> logger)
|
|
{
|
|
_routeRepository = routeRepository;
|
|
_regionRepository = regionRepository;
|
|
_serviceProvider = serviceProvider;
|
|
_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();
|
|
|
|
if (pendingRoutes.Count > 0)
|
|
{
|
|
_logger.LogInformation("Processing {Count} route(s) with pending maps: {RouteIds}",
|
|
pendingRoutes.Count, string.Join(", ", pendingRoutes.Select(r => r.Id)));
|
|
}
|
|
|
|
foreach (var route in pendingRoutes)
|
|
{
|
|
if (cancellationToken.IsCancellationRequested)
|
|
break;
|
|
|
|
try
|
|
{
|
|
await ProcessRouteSequentiallyAsync(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 ProcessRouteSequentiallyAsync(Guid routeId, CancellationToken cancellationToken)
|
|
{
|
|
var route = await _routeRepository.GetByIdAsync(routeId);
|
|
if (route == null)
|
|
{
|
|
_logger.LogWarning("Route {RouteId}: Route not found, skipping processing", routeId);
|
|
return;
|
|
}
|
|
|
|
if (!route.RequestMaps)
|
|
{
|
|
_logger.LogInformation("Route {RouteId}: RequestMaps=false, skipping processing", routeId);
|
|
return;
|
|
}
|
|
|
|
if (route.MapsReady)
|
|
{
|
|
_logger.LogInformation("Route {RouteId}: MapsReady=true, skipping processing", routeId);
|
|
return;
|
|
}
|
|
|
|
_logger.LogInformation("Route {RouteId}: Starting processing check - RequestMaps={RequestMaps}, MapsReady={MapsReady}",
|
|
routeId, route.RequestMaps, route.MapsReady);
|
|
|
|
var routePointsList = (await _routeRepository.GetRoutePointsAsync(routeId)).ToList();
|
|
var regionIdsList = (await _routeRepository.GetRegionIdsByRouteAsync(routeId)).ToList();
|
|
var geofenceRegionIdsList = (await _routeRepository.GetGeofenceRegionIdsByRouteAsync(routeId)).ToList();
|
|
|
|
var allRegionIds = regionIdsList.Union(geofenceRegionIdsList).ToList();
|
|
|
|
if (regionIdsList.Count == 0 && routePointsList.Count > 0)
|
|
{
|
|
_logger.LogInformation("Route {RouteId}: No route point regions linked yet. Will create regions for {PointCount} route points", routeId, routePointsList.Count);
|
|
|
|
using var scope = _serviceProvider.CreateScope();
|
|
var regionService = scope.ServiceProvider.GetRequiredService<Common.Interfaces.IRegionService>();
|
|
|
|
_logger.LogInformation("Route {RouteId}: Starting parallel region processing for {PointCount} points",
|
|
routeId, routePointsList.Count);
|
|
|
|
var queuedRegionIds = new List<Guid>();
|
|
|
|
foreach (var point in routePointsList)
|
|
{
|
|
var regionId = Guid.NewGuid();
|
|
|
|
_logger.LogInformation("RouteProcessingService - Creating region {RegionId} for route {RouteId} at point: Lat={Lat:F12}, Lon={Lon:F12}",
|
|
regionId, routeId, point.Latitude, point.Longitude);
|
|
|
|
await regionService.RequestRegionAsync(
|
|
regionId,
|
|
point.Latitude,
|
|
point.Longitude,
|
|
route.RegionSizeMeters,
|
|
route.ZoomLevel,
|
|
stitchTiles: false);
|
|
|
|
await _routeRepository.LinkRouteToRegionAsync(routeId, regionId);
|
|
queuedRegionIds.Add(regionId);
|
|
}
|
|
|
|
_logger.LogInformation("Route {RouteId}: Queued all {Count} regions for parallel processing. Region IDs: {RegionIds}",
|
|
routeId, queuedRegionIds.Count, string.Join(", ", queuedRegionIds.Take(5)) + (queuedRegionIds.Count > 5 ? "..." : ""));
|
|
return;
|
|
}
|
|
|
|
var regions = new List<DataAccess.Models.RegionEntity>();
|
|
foreach (var regionId in allRegionIds)
|
|
{
|
|
var region = await _regionRepository.GetByIdAsync(regionId);
|
|
if (region != null)
|
|
{
|
|
regions.Add(region);
|
|
}
|
|
}
|
|
|
|
var completedRegions = regions.Where(r => r.Status == "completed").ToList();
|
|
var failedRegions = regions.Where(r => r.Status == "failed").ToList();
|
|
var processingRegions = regions.Where(r => r.Status == "queued" || r.Status == "processing").ToList();
|
|
|
|
var completedRoutePointRegions = completedRegions.Where(r => !geofenceRegionIdsList.Contains(r.Id)).ToList();
|
|
var completedGeofenceRegions = completedRegions.Where(r => geofenceRegionIdsList.Contains(r.Id)).ToList();
|
|
|
|
_logger.LogInformation("Route {RouteId}: Region counts - Total allRegionIds={AllCount}, regionIdsList={RoutePointCount}, geofenceRegionIdsList={GeofenceCount}",
|
|
routeId, allRegionIds.Count, regionIdsList.Count, geofenceRegionIdsList.Count);
|
|
_logger.LogInformation("Route {RouteId}: Status breakdown - Completed={Completed} (RoutePoint={CompletedRP}, Geofence={CompletedGF}), Failed={Failed}, Processing={Processing}",
|
|
routeId, completedRegions.Count, completedRoutePointRegions.Count, completedGeofenceRegions.Count, failedRegions.Count, processingRegions.Count);
|
|
|
|
var hasRoutePointRegions = regionIdsList.Count > 0;
|
|
var hasEnoughRoutePointRegions = !hasRoutePointRegions || completedRoutePointRegions.Count >= routePointsList.Count;
|
|
var hasAllGeofenceRegions = geofenceRegionIdsList.Count == 0 || completedGeofenceRegions.Count >= geofenceRegionIdsList.Count;
|
|
var hasEnoughCompleted = hasEnoughRoutePointRegions && hasAllGeofenceRegions;
|
|
|
|
_logger.LogInformation("Route {RouteId}: Condition checks - hasRoutePointRegions={HasRP}, hasEnoughRoutePointRegions={HasEnoughRP} (need {NeedRP}), hasAllGeofenceRegions={HasAllGF} (need {NeedGF}), hasEnoughCompleted={HasEnough}",
|
|
routeId, hasRoutePointRegions, hasEnoughRoutePointRegions, routePointsList.Count, hasAllGeofenceRegions, geofenceRegionIdsList.Count, hasEnoughCompleted);
|
|
|
|
var activeRegions = completedRegions.Count + processingRegions.Count;
|
|
var shouldRetryFailed = failedRegions.Count > 0 && !hasEnoughCompleted && activeRegions < allRegionIds.Count;
|
|
|
|
if (hasEnoughCompleted)
|
|
{
|
|
_logger.LogInformation("Route {RouteId}: Have {RoutePointCompleted}/{RoutePointRequired} route point regions and {GeofenceCompleted}/{GeofenceRequired} geofence regions completed. Generating final maps. Ignoring {Processing} processing and {Failed} failed regions.",
|
|
routeId, completedRoutePointRegions.Count, routePointsList.Count, completedGeofenceRegions.Count, geofenceRegionIdsList.Count, processingRegions.Count, failedRegions.Count);
|
|
|
|
var orderedRouteRegions = MatchRegionsToRoutePoints(routePointsList, completedRoutePointRegions, routeId);
|
|
var routeRegionIds = orderedRouteRegions.Select(r => r.Id).ToList();
|
|
var allRegionIdsForStitching = routeRegionIds.Concat(completedGeofenceRegions.Select(r => r.Id)).Distinct();
|
|
var geofenceRegionIdsForBorders = completedGeofenceRegions.Select(r => r.Id).ToList();
|
|
|
|
await GenerateRouteMapsAsync(routeId, route, allRegionIdsForStitching, geofenceRegionIdsForBorders, routePointsList, cancellationToken);
|
|
return;
|
|
}
|
|
|
|
if (shouldRetryFailed)
|
|
{
|
|
_logger.LogWarning("Route {RouteId}: {FailedCount} region(s) failed: {FailedRegions}. Active regions: {ActiveCount}/{RequiredCount}. Attempting to retry with new regions.",
|
|
routeId, failedRegions.Count, string.Join(", ", failedRegions.Select(r => r.Id)), activeRegions, routePointsList.Count);
|
|
|
|
using var scope = _serviceProvider.CreateScope();
|
|
var regionService = scope.ServiceProvider.GetRequiredService<Common.Interfaces.IRegionService>();
|
|
|
|
foreach (var failedRegion in failedRegions)
|
|
{
|
|
var newRegionId = Guid.NewGuid();
|
|
_logger.LogInformation("Route {RouteId}: Retrying failed region {OldRegionId} with new region {NewRegionId}",
|
|
routeId, failedRegion.Id, newRegionId);
|
|
|
|
await regionService.RequestRegionAsync(
|
|
newRegionId,
|
|
failedRegion.Latitude,
|
|
failedRegion.Longitude,
|
|
failedRegion.SizeMeters,
|
|
failedRegion.ZoomLevel,
|
|
stitchTiles: false);
|
|
|
|
await _routeRepository.LinkRouteToRegionAsync(routeId, newRegionId);
|
|
}
|
|
|
|
_logger.LogInformation("Route {RouteId}: Queued {Count} retry regions", routeId, failedRegions.Count);
|
|
return;
|
|
}
|
|
|
|
var anyProcessing = processingRegions.Count > 0;
|
|
if (anyProcessing)
|
|
{
|
|
_logger.LogInformation("Route {RouteId}: Progress - {RoutePointCompleted}/{RoutePointRequired} route point regions, {GeofenceCompleted}/{GeofenceRequired} geofence regions completed, {Processing} still processing, {Failed} failed (will retry if needed)",
|
|
routeId, completedRoutePointRegions.Count, routePointsList.Count, completedGeofenceRegions.Count, geofenceRegionIdsList.Count, processingRegions.Count, failedRegions.Count);
|
|
return;
|
|
}
|
|
|
|
_logger.LogWarning("Route {RouteId}: No processing regions, but not enough completed. This is an unexpected state - hasEnoughCompleted={HasEnough}, shouldRetryFailed={ShouldRetry}, anyProcessing={AnyProcessing}",
|
|
routeId, hasEnoughCompleted, shouldRetryFailed, anyProcessing);
|
|
}
|
|
|
|
private async Task GenerateRouteMapsAsync(
|
|
Guid routeId,
|
|
DataAccess.Models.RouteEntity route,
|
|
IEnumerable<Guid> regionIds,
|
|
List<Guid> geofenceRegionIds,
|
|
List<DataAccess.Models.RoutePointEntity> routePoints,
|
|
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("Route {RouteId}: Region {RegionId} CSV not found", routeId, regionId);
|
|
continue;
|
|
}
|
|
|
|
var isGeofence = geofenceRegionIds.Contains(regionId);
|
|
_logger.LogInformation("Route {RouteId}: Processing region {RegionId} ({Type}) - Lat={Lat}, Lon={Lon}, Size={Size}m, CSV={CsvPath}",
|
|
routeId, regionId, isGeofence ? "GEOFENCE" : "RoutePoint",
|
|
region.Latitude, region.Longitude, region.SizeMeters, region.CsvFilePath);
|
|
|
|
var csvLines = await File.ReadAllLinesAsync(region.CsvFilePath, cancellationToken);
|
|
|
|
var lineNumber = 0;
|
|
foreach (var line in csvLines.Skip(1))
|
|
{
|
|
lineNumber++;
|
|
var parts = line.Split(',');
|
|
if (parts.Length < 3) continue;
|
|
|
|
if (!double.TryParse(parts[0], out var lat))
|
|
{
|
|
_logger.LogWarning("Route {RouteId} - Failed to parse latitude from CSV line {LineNumber}: {Line}", routeId, lineNumber, line);
|
|
continue;
|
|
}
|
|
if (!double.TryParse(parts[1], out var lon))
|
|
{
|
|
_logger.LogWarning("Route {RouteId} - Failed to parse longitude from CSV line {LineNumber}: {Line}", routeId, lineNumber, line);
|
|
continue;
|
|
}
|
|
var filePath = parts[2];
|
|
|
|
if (lineNumber <= 3)
|
|
{
|
|
_logger.LogInformation("Route {RouteId} - Reading tile from region {RegionId} CSV: Lat={Lat:F12}, Lon={Lon:F12}",
|
|
routeId, regionId, lat, lon);
|
|
}
|
|
|
|
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)
|
|
{
|
|
var geofencePolygonBounds = new List<(int MinX, int MinY, int MaxX, int MaxY)>();
|
|
|
|
var geofencesByPolygon = await _routeRepository.GetGeofenceRegionsByPolygonAsync(routeId);
|
|
|
|
foreach (var (polygonIndex, polygonRegionIds) in geofencesByPolygon.OrderBy(kvp => kvp.Key))
|
|
{
|
|
int? minX = null, minY = null, maxX = null, maxY = null;
|
|
|
|
foreach (var geofenceId in polygonRegionIds)
|
|
{
|
|
var region = await _regionRepository.GetByIdAsync(geofenceId);
|
|
if (region != null && !string.IsNullOrEmpty(region.CsvFilePath) && File.Exists(region.CsvFilePath))
|
|
{
|
|
var csvLines = await File.ReadAllLinesAsync(region.CsvFilePath, cancellationToken);
|
|
|
|
foreach (var line in csvLines.Skip(1))
|
|
{
|
|
var parts = line.Split(',');
|
|
if (parts.Length >= 3)
|
|
{
|
|
if (double.TryParse(parts[0], out var lat) && double.TryParse(parts[1], out var lon))
|
|
{
|
|
var tile = GeoUtils.WorldToTilePos(new Common.DTO.GeoPoint { Lat = lat, Lon = lon }, route.ZoomLevel);
|
|
minX = minX == null ? tile.x : Math.Min(minX.Value, tile.x);
|
|
minY = minY == null ? tile.y : Math.Min(minY.Value, tile.y);
|
|
maxX = maxX == null ? tile.x : Math.Max(maxX.Value, tile.x);
|
|
maxY = maxY == null ? tile.y : Math.Max(maxY.Value, tile.y);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
if (minX.HasValue && minY.HasValue && maxX.HasValue && maxY.HasValue)
|
|
{
|
|
geofencePolygonBounds.Add((minX.Value, minY.Value, maxX.Value, maxY.Value));
|
|
_logger.LogInformation("Route {RouteId}: Polygon {PolygonIndex} tile bounds: X=[{MinX}..{MaxX}], Y=[{MinY}..{MaxY}]",
|
|
routeId, polygonIndex, minX.Value, maxX.Value, minY.Value, maxY.Value);
|
|
}
|
|
}
|
|
|
|
stitchedImagePath = Path.Combine(readyDir, $"route_{routeId}_stitched.jpg");
|
|
|
|
await StitchRouteTilesAsync(allTiles.Values.ToList(), stitchedImagePath, route.ZoomLevel, geofencePolygonBounds, routePoints, 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,
|
|
List<(int MinX, int MinY, int MaxX, int MaxY)> geofencePolygonBounds,
|
|
List<DataAccess.Models.RoutePointEntity> routePoints,
|
|
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 {ExpectedWidth}x{ExpectedHeight}",
|
|
tile.FilePath, tileImage.Width, tileImage.Height, tileSizePixels, 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++;
|
|
}
|
|
}
|
|
|
|
if (geofencePolygonBounds.Count > 0)
|
|
{
|
|
_logger.LogInformation("Drawing {Count} geofence polygon borders on image {Width}x{Height} (grid: minX={MinX}, minY={MinY})",
|
|
geofencePolygonBounds.Count, imageWidth, imageHeight, minX, minY);
|
|
|
|
for (int i = 0; i < geofencePolygonBounds.Count; i++)
|
|
{
|
|
var (geoMinX, geoMinY, geoMaxX, geoMaxY) = geofencePolygonBounds[i];
|
|
|
|
_logger.LogInformation("Polygon {Index}: Tile range - X=[{MinX}..{MaxX}], Y=[{MinY}..{MaxY}]",
|
|
i, geoMinX, geoMaxX, geoMinY, geoMaxY);
|
|
|
|
var x1 = (geoMinX - minX) * tileSizePixels;
|
|
var y1 = (geoMinY - minY + 1) * tileSizePixels;
|
|
var x2 = (geoMaxX - minX + 2) * tileSizePixels - 1;
|
|
var y2 = (geoMaxY - minY + 1) * tileSizePixels - 1;
|
|
|
|
_logger.LogInformation("Polygon {Index}: Pixel coords before clipping - ({X1},{Y1}) to ({X2},{Y2})",
|
|
i, x1, y1, x2, y2);
|
|
|
|
x1 = Math.Max(0, Math.Min(x1, imageWidth - 1));
|
|
y1 = Math.Max(0, Math.Min(y1, imageHeight - 1));
|
|
x2 = Math.Max(0, Math.Min(x2, imageWidth - 1));
|
|
y2 = Math.Max(0, Math.Min(y2, imageHeight - 1));
|
|
|
|
if (x1 >= 0 && y1 >= 0 && x2 < imageWidth && y2 < imageHeight && x2 > x1 && y2 > y1)
|
|
{
|
|
_logger.LogInformation("Polygon {Index}: Drawing border at pixel coords ({X1},{Y1}) to ({X2},{Y2})",
|
|
i, x1, y1, x2, y2);
|
|
|
|
DrawRectangleBorder(stitchedImage, x1, y1, x2, y2, new Rgb24(255, 255, 0));
|
|
|
|
_logger.LogInformation("Polygon {Index}: Successfully drew geofence border", i);
|
|
}
|
|
else
|
|
{
|
|
_logger.LogWarning("Polygon {Index}: Border out of bounds or invalid - ({X1},{Y1}) to ({X2},{Y2}), image size: {Width}x{Height}",
|
|
i, x1, y1, x2, y2, imageWidth, imageHeight);
|
|
}
|
|
}
|
|
}
|
|
|
|
_logger.LogInformation("Drawing {Count} route point crosses on map", routePoints.Count);
|
|
|
|
foreach (var point in routePoints)
|
|
{
|
|
var geoPoint = new Common.DTO.GeoPoint { Lat = point.Latitude, Lon = point.Longitude };
|
|
var (tileX, tileY) = GeoUtils.WorldToTilePos(geoPoint, zoomLevel);
|
|
|
|
var pixelX = (tileX - minX) * tileSizePixels + tileSizePixels / 2;
|
|
var pixelY = (tileY - minY) * tileSizePixels + tileSizePixels / 2;
|
|
|
|
if (pixelX >= 0 && pixelX < imageWidth && pixelY >= 0 && pixelY < imageHeight)
|
|
{
|
|
DrawCross(stitchedImage, pixelX, pixelY, new Rgb24(255, 0, 0), 50);
|
|
}
|
|
}
|
|
|
|
_logger.LogInformation("Finished drawing route point crosses");
|
|
|
|
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 List<DataAccess.Models.RegionEntity> MatchRegionsToRoutePoints(
|
|
List<DataAccess.Models.RoutePointEntity> routePoints,
|
|
List<DataAccess.Models.RegionEntity> regions,
|
|
Guid routeId)
|
|
{
|
|
var orderedRegions = new List<DataAccess.Models.RegionEntity>();
|
|
var availableRegions = new List<DataAccess.Models.RegionEntity>(regions);
|
|
|
|
foreach (var point in routePoints)
|
|
{
|
|
var matchedRegion = availableRegions
|
|
.OrderBy(r => CalculateDistance(point.Latitude, point.Longitude, r.Latitude, r.Longitude))
|
|
.FirstOrDefault();
|
|
|
|
if (matchedRegion != null)
|
|
{
|
|
orderedRegions.Add(matchedRegion);
|
|
availableRegions.Remove(matchedRegion);
|
|
|
|
var distance = CalculateDistance(point.Latitude, point.Longitude, matchedRegion.Latitude, matchedRegion.Longitude);
|
|
_logger.LogInformation("Route {RouteId}: Matched route point Seq={Seq} ({Lat:F6},{Lon:F6}) to region {RegionId} ({RegLat:F6},{RegLon:F6}), distance={Distance:F2}m",
|
|
routeId, point.SequenceNumber, point.Latitude, point.Longitude,
|
|
matchedRegion.Id, matchedRegion.Latitude, matchedRegion.Longitude, distance);
|
|
}
|
|
else
|
|
{
|
|
_logger.LogWarning("Route {RouteId}: No region found for route point Seq={Seq} ({Lat:F6},{Lon:F6})",
|
|
routeId, point.SequenceNumber, point.Latitude, point.Longitude);
|
|
}
|
|
}
|
|
|
|
return orderedRegions;
|
|
}
|
|
|
|
private static double CalculateDistance(double lat1, double lon1, double lat2, double lon2)
|
|
{
|
|
const double earthRadiusMeters = 6371000;
|
|
var dLat = (lat2 - lat1) * Math.PI / 180.0;
|
|
var dLon = (lon2 - lon1) * Math.PI / 180.0;
|
|
|
|
var a = Math.Sin(dLat / 2) * Math.Sin(dLat / 2) +
|
|
Math.Cos(lat1 * Math.PI / 180.0) * Math.Cos(lat2 * Math.PI / 180.0) *
|
|
Math.Sin(dLon / 2) * Math.Sin(dLon / 2);
|
|
var c = 2 * Math.Atan2(Math.Sqrt(a), Math.Sqrt(1 - a));
|
|
|
|
return earthRadiusMeters * c;
|
|
}
|
|
|
|
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);
|
|
}
|
|
|
|
private static void DrawRectangleBorder(Image<Rgb24> image, int x1, int y1, int x2, int y2, Rgb24 color)
|
|
{
|
|
const int thickness = 5;
|
|
|
|
for (int t = 0; t < thickness; t++)
|
|
{
|
|
for (int x = x1; x <= x2; x++)
|
|
{
|
|
int topY = y1 + t;
|
|
int bottomY = y2 - t;
|
|
if (x >= 0 && x < image.Width && topY >= 0 && topY < image.Height)
|
|
image[x, topY] = color;
|
|
if (x >= 0 && x < image.Width && bottomY >= 0 && bottomY < image.Height)
|
|
image[x, bottomY] = color;
|
|
}
|
|
|
|
for (int y = y1; y <= y2; y++)
|
|
{
|
|
int leftX = x1 + t;
|
|
int rightX = x2 - t;
|
|
if (leftX >= 0 && leftX < image.Width && y >= 0 && y < image.Height)
|
|
image[leftX, y] = color;
|
|
if (rightX >= 0 && rightX < image.Width && y >= 0 && y < image.Height)
|
|
image[rightX, y] = color;
|
|
}
|
|
}
|
|
}
|
|
|
|
private static void DrawCross(Image<Rgb24> image, int centerX, int centerY, Rgb24 color, int armLength)
|
|
{
|
|
const int thickness = 10;
|
|
int halfThickness = thickness / 2;
|
|
|
|
for (int dx = -armLength; dx <= armLength; dx++)
|
|
{
|
|
for (int t = -halfThickness; t <= halfThickness; t++)
|
|
{
|
|
int x = centerX + dx;
|
|
int y = centerY + t;
|
|
if (x >= 0 && x < image.Width && y >= 0 && y < image.Height)
|
|
image[x, y] = color;
|
|
}
|
|
}
|
|
|
|
for (int dy = -armLength; dy <= armLength; dy++)
|
|
{
|
|
for (int t = -halfThickness; t <= halfThickness; t++)
|
|
{
|
|
int x = centerX + t;
|
|
int y = centerY + dy;
|
|
if (x >= 0 && x < image.Width && y >= 0 && y < image.Height)
|
|
image[x, y] = color;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
public class TileInfo
|
|
{
|
|
public double Latitude { get; set; }
|
|
public double Longitude { get; set; }
|
|
public string FilePath { get; set; } = string.Empty;
|
|
}
|
|
|