Files
satellite-provider/SatelliteProvider.Services.RouteManagement/RouteImageRenderer.cs
T
Oleksandr Bezdieniezhnykh 6f23120c49 [AZ-364] [AZ-360] Refactor C11+C08: decompose RouteProcessingService
Extracts RouteRegionMatcher, RouteCsvWriter, RouteSummaryWriter,
RouteImageRenderer, TilesZipBuilder, RegionFileCleaner from the
~750-LOC RouteProcessingService god-class. Moves TileInfo to its
own file as a sealed record. Replaces IServiceProvider scope-
locator with a direct IRegionService injection (folds AZ-360 / C08).
Updates DI registration and tests.

Tests: 133 / 133 unit + 5 / 5 smoke green; integration suite exit 0.
Pixel-equivalent stitched route image and byte-equivalent CSV /
summary / ZIP outputs verified through the smoke run.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-11 03:12:49 +03:00

154 lines
5.8 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 const int TileSizePixels = 256;
private readonly StorageConfig _storageConfig;
private readonly ILogger<RouteImageRenderer> _logger;
public RouteImageRenderer(IOptions<StorageConfig> storageConfig, ILogger<RouteImageRenderer> logger)
{
ArgumentNullException.ThrowIfNull(storageConfig);
_storageConfig = storageConfig.Value;
_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);
}
}