mirror of
https://github.com/azaion/satellite-provider.git
synced 2026-06-21 20:11:17 +00:00
1dcd089d39
Promotes 8 operational levers into config keys with defaults that match the prior source literals byte-for-byte: ProcessingConfig: RegionProcessingTimeoutSeconds (300), RouteProcessingPollIntervalSeconds (5), MaxRoutePointSpacingMeters (200), LatLonTolerance (0.0001). MapConfig: TileSizePixels (256), AllowedZoomLevels ([15..19]), RetryBaseDelaySeconds (1), RetryMaxDelaySeconds (30). Sites updated: RegionService, RouteProcessingService, RoutePointGraphBuilder, RouteValidator, RouteService 4-arg ctor, RouteImageRenderer, GoogleMapsDownloaderV2, TileService. Closes LF-2 by forwarding HttpContext.RequestAborted from GetTileByLatLon into the downloader. appsettings.json gains the 8 new keys at default values. Tests: 141 / 141 unit + 5 / 5 smoke green. New ConfigDefaultsTests pins defaults to original literals; new TileService unit test asserts CT identity from caller to downloader (AZ-371 AC-3). Co-authored-by: Cursor <cursoragent@cursor.com>
155 lines
6.0 KiB
C#
155 lines
6.0 KiB
C#
using Microsoft.Extensions.Logging;
|
|
using Microsoft.Extensions.Options;
|
|
using SatelliteProvider.Common.Configs;
|
|
using SatelliteProvider.Common.DTO;
|
|
using SatelliteProvider.Common.Imaging;
|
|
using SatelliteProvider.Common.Utils;
|
|
using SatelliteProvider.DataAccess.Models;
|
|
using SixLabors.ImageSharp;
|
|
using SixLabors.ImageSharp.PixelFormats;
|
|
|
|
namespace SatelliteProvider.Services.RouteManagement;
|
|
|
|
// AZ-364 / C11: extracted from RouteProcessingService.StitchRouteTilesAsync.
|
|
// Owns the route_<id>_stitched.jpg path computation, the per-tile filename
|
|
// parsing into TilePlacement, the call into the shared TileGridStitcher
|
|
// (Common), and the route-specific overlays (yellow geofence rectangles,
|
|
// red 50-arm crosses on each route point). Behavior preserved verbatim per
|
|
// AC-2 (pixel-for-pixel identical output for existing scenarios).
|
|
public class RouteImageRenderer
|
|
{
|
|
private readonly StorageConfig _storageConfig;
|
|
private readonly int _tileSizePixels;
|
|
private readonly ILogger<RouteImageRenderer> _logger;
|
|
|
|
public RouteImageRenderer(IOptions<StorageConfig> storageConfig, IOptions<MapConfig> mapConfig, ILogger<RouteImageRenderer> logger)
|
|
{
|
|
ArgumentNullException.ThrowIfNull(storageConfig);
|
|
ArgumentNullException.ThrowIfNull(mapConfig);
|
|
_storageConfig = storageConfig.Value;
|
|
_tileSizePixels = mapConfig.Value.TileSizePixels;
|
|
_logger = logger;
|
|
}
|
|
|
|
public async Task<string?> RenderAsync(
|
|
Guid routeId,
|
|
IReadOnlyList<TileInfo> tiles,
|
|
int zoomLevel,
|
|
IReadOnlyList<(int MinX, int MinY, int MaxX, int MaxY)> geofencePolygonBounds,
|
|
IReadOnlyList<RoutePointEntity> routePoints,
|
|
CancellationToken cancellationToken = default)
|
|
{
|
|
ArgumentNullException.ThrowIfNull(tiles);
|
|
ArgumentNullException.ThrowIfNull(geofencePolygonBounds);
|
|
ArgumentNullException.ThrowIfNull(routePoints);
|
|
|
|
if (tiles.Count == 0)
|
|
{
|
|
_logger.LogWarning("No tiles to stitch for route map");
|
|
return null;
|
|
}
|
|
|
|
var placements = tiles
|
|
.Select(t =>
|
|
{
|
|
var (tileX, tileY) = ExtractTileCoordinatesFromFilename(t.FilePath);
|
|
return new TilePlacement(tileX, tileY, t.FilePath);
|
|
})
|
|
.Where(p => p.TileX >= 0 && p.TileY >= 0)
|
|
.ToList();
|
|
|
|
if (placements.Count == 0)
|
|
{
|
|
_logger.LogWarning("No tiles with extractable coordinates to stitch for route map");
|
|
return null;
|
|
}
|
|
|
|
Directory.CreateDirectory(_storageConfig.ReadyDirectory);
|
|
var outputPath = Path.Combine(_storageConfig.ReadyDirectory, $"route_{routeId}_stitched.jpg");
|
|
|
|
var stitcher = new TileGridStitcher();
|
|
var result = await stitcher.StitchAsync(
|
|
placements,
|
|
_tileSizePixels,
|
|
deduplicateByTileCoords: true,
|
|
swallowTileLoadErrors: true,
|
|
cancellationToken);
|
|
|
|
using var stitchedImage = result.Image;
|
|
|
|
foreach (var missing in result.MissingTiles)
|
|
{
|
|
if (missing.Reason == MissingTileReason.LoadFailed)
|
|
{
|
|
_logger.LogWarning(missing.Error, "Failed to load tile at {FilePath}, leaving black", missing.Tile.FilePath);
|
|
}
|
|
else
|
|
{
|
|
_logger.LogWarning("Tile file not found: {FilePath}, leaving black", missing.Tile.FilePath);
|
|
}
|
|
}
|
|
|
|
var minX = result.MinX;
|
|
var minY = result.MinY;
|
|
var imageWidth = result.ImageWidth;
|
|
var imageHeight = result.ImageHeight;
|
|
var yellow = new Rgb24(255, 255, 0);
|
|
var red = new Rgb24(255, 0, 0);
|
|
|
|
foreach (var (geoMinX, geoMinY, geoMaxX, geoMaxY) in geofencePolygonBounds)
|
|
{
|
|
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;
|
|
|
|
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)
|
|
{
|
|
stitcher.DrawRectangleBorder(
|
|
stitchedImage,
|
|
new Rectangle(x1, y1, x2 - x1 + 1, y2 - y1 + 1),
|
|
yellow);
|
|
}
|
|
}
|
|
|
|
foreach (var point in routePoints)
|
|
{
|
|
var geoPoint = new GeoPoint(point.Latitude, 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)
|
|
{
|
|
stitcher.DrawCross(stitchedImage, new Point(pixelX, pixelY), red, 50);
|
|
}
|
|
}
|
|
|
|
await stitchedImage.SaveAsJpegAsync(outputPath, cancellationToken);
|
|
return outputPath;
|
|
}
|
|
|
|
// Parses tile_<zoom>_<x>_<y>_<timestamp>.jpg via StorageConfig.TryExtractTileCoordinates.
|
|
// On parse failure, logs a warning and returns the (-1, -1) sentinel — the
|
|
// RenderAsync pipeline filters those out before stitching. ArgumentNullException
|
|
// on null input is propagated from StorageConfig.
|
|
internal (int TileX, int TileY) ExtractTileCoordinatesFromFilename(string filePath)
|
|
{
|
|
if (StorageConfig.TryExtractTileCoordinates(filePath, out var tileX, out var tileY))
|
|
{
|
|
return (tileX, tileY);
|
|
}
|
|
|
|
_logger.LogWarning(
|
|
"Could not extract tile coordinates from filename {FilePath}; expected pattern tile_<zoom>_<x>_<y>_<timestamp>",
|
|
filePath);
|
|
return (-1, -1);
|
|
}
|
|
}
|