diff --git a/SatelliteProvider.Common/Imaging/TileGridStitcher.cs b/SatelliteProvider.Common/Imaging/TileGridStitcher.cs new file mode 100644 index 0000000..600c00f --- /dev/null +++ b/SatelliteProvider.Common/Imaging/TileGridStitcher.cs @@ -0,0 +1,213 @@ +using SixLabors.ImageSharp; +using SixLabors.ImageSharp.PixelFormats; +using SixLabors.ImageSharp.Processing; + +namespace SatelliteProvider.Common.Imaging; + +// AZ-367 / C14: shared tile-grid stitcher. +// Both region and route map generation previously implemented the same placement +// loop (min/max of TileX/TileY, allocate Image, foreach tile load + DrawImage +// at (x-minX, y-minY) * tileSizePixels). The overlays (region center cross / route +// geofence rectangles + route point crosses) differ and stay at the call sites. +public sealed class TileGridStitcher +{ + public async Task StitchAsync( + IEnumerable tiles, + int tileSizePixels, + bool deduplicateByTileCoords, + bool swallowTileLoadErrors, + CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(tiles); + if (tileSizePixels <= 0) + { + throw new ArgumentOutOfRangeException(nameof(tileSizePixels), tileSizePixels, "Tile size must be positive."); + } + + var sourceList = tiles.ToList(); + if (sourceList.Count == 0) + { + throw new InvalidOperationException("No tiles to stitch."); + } + + var orderedTiles = deduplicateByTileCoords + ? sourceList + .GroupBy(t => (t.TileX, t.TileY)) + .Select(g => g.First()) + .OrderBy(t => t.TileY) + .ThenBy(t => t.TileX) + .ToList() + : sourceList; + + var minX = orderedTiles.Min(t => t.TileX); + var maxX = orderedTiles.Max(t => t.TileX); + var minY = orderedTiles.Min(t => t.TileY); + var maxY = orderedTiles.Max(t => t.TileY); + + var gridWidth = maxX - minX + 1; + var gridHeight = maxY - minY + 1; + var imageWidth = gridWidth * tileSizePixels; + var imageHeight = gridHeight * tileSizePixels; + + var stitchedImage = new Image(imageWidth, imageHeight); + var placed = new List(orderedTiles.Count); + var missing = new List(); + + try + { + foreach (var tile in orderedTiles) + { + cancellationToken.ThrowIfCancellationRequested(); + + if (!File.Exists(tile.FilePath)) + { + missing.Add(new MissingTile(tile, MissingTileReason.FileNotFound, Error: null)); + continue; + } + + Image? tileImage = null; + try + { + tileImage = await Image.LoadAsync(tile.FilePath, cancellationToken); + var destX = (tile.TileX - minX) * tileSizePixels; + var destY = (tile.TileY - minY) * tileSizePixels; + stitchedImage.Mutate(ctx => ctx.DrawImage(tileImage, new Point(destX, destY), 1f)); + placed.Add(tile); + } + catch (Exception ex) when (swallowTileLoadErrors && ex is not OperationCanceledException) + { + missing.Add(new MissingTile(tile, MissingTileReason.LoadFailed, ex)); + } + finally + { + tileImage?.Dispose(); + } + } + } + catch + { + stitchedImage.Dispose(); + throw; + } + + return new StitchResult( + Image: stitchedImage, + MinX: minX, + MinY: minY, + MaxX: maxX, + MaxY: maxY, + TileSizePixels: tileSizePixels, + ImageWidth: imageWidth, + ImageHeight: imageHeight, + PlacedTiles: placed, + MissingTiles: missing); + } + + public void DrawCross(Image image, Point center, Rgb24 color, int armLength, int thickness = 10) + { + ArgumentNullException.ThrowIfNull(image); + if (armLength < 0 || thickness < 1) + { + return; + } + + var halfThickness = thickness / 2; + var width = image.Width; + var height = image.Height; + + for (int dx = -armLength; dx <= armLength; dx++) + { + for (int t = -halfThickness; t <= halfThickness; t++) + { + int x = center.X + dx; + int y = center.Y + t; + if (x >= 0 && x < width && y >= 0 && y < height) + { + image[x, y] = color; + } + } + } + + for (int dy = -armLength; dy <= armLength; dy++) + { + for (int t = -halfThickness; t <= halfThickness; t++) + { + int x = center.X + t; + int y = center.Y + dy; + if (x >= 0 && x < width && y >= 0 && y < height) + { + image[x, y] = color; + } + } + } + } + + public void DrawRectangleBorder(Image image, Rectangle rect, Rgb24 color, int thickness = 5) + { + ArgumentNullException.ThrowIfNull(image); + if (thickness < 1) + { + return; + } + + var x1 = rect.X; + var y1 = rect.Y; + var x2 = rect.X + rect.Width - 1; + var y2 = rect.Y + rect.Height - 1; + var width = image.Width; + var height = image.Height; + + 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 < width && topY >= 0 && topY < height) + { + image[x, topY] = color; + } + if (x >= 0 && x < width && bottomY >= 0 && bottomY < height) + { + image[x, bottomY] = color; + } + } + + for (int y = y1; y <= y2; y++) + { + int leftX = x1 + t; + int rightX = x2 - t; + if (leftX >= 0 && leftX < width && y >= 0 && y < height) + { + image[leftX, y] = color; + } + if (rightX >= 0 && rightX < width && y >= 0 && y < height) + { + image[rightX, y] = color; + } + } + } + } +} + +public sealed record TilePlacement(int TileX, int TileY, string FilePath); + +public sealed record StitchResult( + Image Image, + int MinX, + int MinY, + int MaxX, + int MaxY, + int TileSizePixels, + int ImageWidth, + int ImageHeight, + IReadOnlyList PlacedTiles, + IReadOnlyList MissingTiles); + +public sealed record MissingTile(TilePlacement Tile, MissingTileReason Reason, Exception? Error); + +public enum MissingTileReason +{ + FileNotFound, + LoadFailed +} diff --git a/SatelliteProvider.Common/SatelliteProvider.Common.csproj b/SatelliteProvider.Common/SatelliteProvider.Common.csproj index 3a63532..ed767a0 100644 --- a/SatelliteProvider.Common/SatelliteProvider.Common.csproj +++ b/SatelliteProvider.Common/SatelliteProvider.Common.csproj @@ -6,4 +6,8 @@ enable + + + + diff --git a/SatelliteProvider.Services.RegionProcessing/RegionService.cs b/SatelliteProvider.Services.RegionProcessing/RegionService.cs index 69b4d5c..8983307 100644 --- a/SatelliteProvider.Services.RegionProcessing/RegionService.cs +++ b/SatelliteProvider.Services.RegionProcessing/RegionService.cs @@ -3,13 +3,13 @@ using Microsoft.Extensions.Options; using SatelliteProvider.Common.Configs; using SatelliteProvider.Common.DTO; using SatelliteProvider.Common.Exceptions; +using SatelliteProvider.Common.Imaging; using SatelliteProvider.Common.Interfaces; using SatelliteProvider.Common.Utils; using SatelliteProvider.DataAccess.Models; using SatelliteProvider.DataAccess.Repositories; using SixLabors.ImageSharp; using SixLabors.ImageSharp.PixelFormats; -using SixLabors.ImageSharp.Processing; namespace SatelliteProvider.Services.RegionProcessing; @@ -224,69 +224,56 @@ public class RegionService : IRegionService } var tileSizePixels = tiles.First().TileSizePixels; - - var tileCoords = tiles.Select(t => + + var placements = tiles.Select(t => { var (x, y) = GeoUtils.WorldToTilePos(new GeoPoint(t.Latitude, t.Longitude), zoomLevel); - return (x, y, t.FilePath); - }).ToList(); + return new TilePlacement(x, y, t.FilePath); + }); - var minX = tileCoords.Min(t => t.x); - var maxX = tileCoords.Max(t => t.x); - var minY = tileCoords.Min(t => t.y); - var maxY = tileCoords.Max(t => t.y); + var stitcher = new TileGridStitcher(); + var result = await stitcher.StitchAsync( + placements, + tileSizePixels, + deduplicateByTileCoords: false, + swallowTileLoadErrors: false, + cancellationToken); - var gridWidth = maxX - minX + 1; - var gridHeight = maxY - minY + 1; - var imageWidth = gridWidth * tileSizePixels; - var imageHeight = gridHeight * tileSizePixels; + using var stitchedImage = result.Image; - using var stitchedImage = new Image(imageWidth, imageHeight); - - foreach (var (x, y, filePath) in tileCoords) + foreach (var missing in result.MissingTiles) { - if (!File.Exists(filePath)) - { - _logger.LogWarning("Tile file not found: {FilePath}", filePath); - continue; - } - - using var tileImage = await Image.LoadAsync(filePath, cancellationToken); - - var destX = (x - minX) * tileSizePixels; - var destY = (y - minY) * tileSizePixels; - - stitchedImage.Mutate(ctx => ctx.DrawImage(tileImage, new Point(destX, destY), 1f)); + _logger.LogWarning("Tile file not found: {FilePath}", missing.Tile.FilePath); } var (centerTileX, centerTileY) = GeoUtils.WorldToTilePos(new GeoPoint(centerLatitude, centerLongitude), zoomLevel); - + var n = Math.Pow(2.0, zoomLevel); var centerTilePixelX = ((centerLongitude + 180.0) / 360.0 * n - centerTileX) * tileSizePixels; var centerTilePixelY = ((1.0 - Math.Log(Math.Tan(centerLatitude * Math.PI / 180.0) + 1.0 / Math.Cos(centerLatitude * Math.PI / 180.0)) / Math.PI) / 2.0 * n - centerTileY) * tileSizePixels; - - var crossX = (int)Math.Round((centerTileX - minX) * tileSizePixels + centerTilePixelX); - var crossY = (int)Math.Round((centerTileY - minY) * tileSizePixels + centerTilePixelY); + + var crossX = (int)Math.Round((centerTileX - result.MinX) * tileSizePixels + centerTilePixelX); + var crossY = (int)Math.Round((centerTileY - result.MinY) * tileSizePixels + centerTilePixelY); var red = new Rgb24(255, 0, 0); - stitchedImage.Mutate(ctx => + var imageWidth = result.ImageWidth; + var imageHeight = result.ImageHeight; + + for (int i = -5; i < 5; i++) { - for (int i = -5; i < 5; i++) + var hx = crossX + i; + var vy = crossY + i; + + if (hx >= 0 && hx < imageWidth && crossY >= 0 && crossY < imageHeight) { - var hx = crossX + i; - var vy = crossY + i; - - if (hx >= 0 && hx < imageWidth && crossY >= 0 && crossY < imageHeight) - { - stitchedImage[hx, crossY] = red; - } - - if (crossX >= 0 && crossX < imageWidth && vy >= 0 && vy < imageHeight) - { - stitchedImage[crossX, vy] = red; - } + stitchedImage[hx, crossY] = red; } - }); + + if (crossX >= 0 && crossX < imageWidth && vy >= 0 && vy < imageHeight) + { + stitchedImage[crossX, vy] = red; + } + } await stitchedImage.SaveAsJpegAsync(outputPath, cancellationToken); diff --git a/SatelliteProvider.Services.RouteManagement/RouteProcessingService.cs b/SatelliteProvider.Services.RouteManagement/RouteProcessingService.cs index 993312f..3dbb474 100644 --- a/SatelliteProvider.Services.RouteManagement/RouteProcessingService.cs +++ b/SatelliteProvider.Services.RouteManagement/RouteProcessingService.cs @@ -4,11 +4,11 @@ using Microsoft.Extensions.Hosting; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Options; using SatelliteProvider.Common.Configs; +using SatelliteProvider.Common.Imaging; using SatelliteProvider.Common.Utils; using SatelliteProvider.DataAccess.Repositories; using SixLabors.ImageSharp; using SixLabors.ImageSharp.PixelFormats; -using SixLabors.ImageSharp.Processing; namespace SatelliteProvider.Services.RouteManagement; @@ -458,103 +458,82 @@ public class RouteProcessingService : BackgroundService const int tileSizePixels = 256; - var tileCoords = tiles.Select(t => - { - var (tileX, tileY) = ExtractTileCoordinatesFromFilename(t.FilePath); - return new + var placements = tiles + .Select(t => { - 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; - - using var stitchedImage = new Image(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) + var (tileX, tileY) = ExtractTileCoordinatesFromFilename(t.FilePath); + return new TilePlacement(tileX, tileY, t.FilePath); + }) + .Where(p => p.TileX >= 0 && p.TileY >= 0) .ToList(); - - int placedTiles = 0; - int missingTiles = 0; - foreach (var tile in uniqueTileCoords) + if (placements.Count == 0) { - var destX = (tile.TileX - minX) * tileSizePixels; - var destY = (tile.TileY - minY) * tileSizePixels; + _logger.LogWarning("No tiles with extractable coordinates to stitch for route map"); + return; + } - if (File.Exists(tile.FilePath)) + 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) { - try - { - using var tileImage = await Image.LoadAsync(tile.FilePath, cancellationToken); - - stitchedImage.Mutate(ctx => ctx.DrawImage(tileImage, new Point(destX, destY), 1f)); - placedTiles++; - } - catch (Exception ex) - { - _logger.LogWarning(ex, "Failed to load tile at {FilePath}, leaving black", tile.FilePath); - missingTiles++; - } + _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", tile.FilePath); - missingTiles++; + _logger.LogWarning("Tile file not found: {FilePath}, leaving black", missing.Tile.FilePath); } } - if (geofencePolygonBounds.Count > 0) + 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) { - for (int i = 0; i < geofencePolygonBounds.Count; i++) + 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) { - var (geoMinX, geoMinY, geoMaxX, geoMaxY) = geofencePolygonBounds[i]; - - 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) - { - DrawRectangleBorder(stitchedImage, x1, y1, x2, y2, new Rgb24(255, 255, 0)); - } + stitcher.DrawRectangleBorder( + stitchedImage, + new Rectangle(x1, y1, x2 - x1 + 1, y2 - y1 + 1), + yellow); } } - + 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); + stitcher.DrawCross(stitchedImage, new Point(pixelX, pixelY), red, 50); } } @@ -599,62 +578,6 @@ public class RouteProcessingService : BackgroundService return (-1, -1); } - private static void DrawRectangleBorder(Image 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 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; - } - } - } - private Task CreateTilesZipAsync( string zipFilePath, IEnumerable tiles, diff --git a/SatelliteProvider.Tests/TileGridStitcherTests.cs b/SatelliteProvider.Tests/TileGridStitcherTests.cs new file mode 100644 index 0000000..d56ca0f --- /dev/null +++ b/SatelliteProvider.Tests/TileGridStitcherTests.cs @@ -0,0 +1,407 @@ +using FluentAssertions; +using SatelliteProvider.Common.Imaging; +using SixLabors.ImageSharp; +using SixLabors.ImageSharp.PixelFormats; +using SixLabors.ImageSharp.Processing; + +namespace SatelliteProvider.Tests; + +public class TileGridStitcherTests : IDisposable +{ + private readonly string _tempDir; + + public TileGridStitcherTests() + { + _tempDir = Path.Combine(Path.GetTempPath(), $"stitcher_tests_{Guid.NewGuid():N}"); + Directory.CreateDirectory(_tempDir); + } + + public void Dispose() + { + if (Directory.Exists(_tempDir)) + { + Directory.Delete(_tempDir, recursive: true); + } + GC.SuppressFinalize(this); + } + + private string CreateSolidTile(string name, int sizePixels, Rgb24 color) + { + var path = Path.Combine(_tempDir, $"{name}.png"); + using var img = new Image(sizePixels, sizePixels); + for (int y = 0; y < sizePixels; y++) + { + for (int x = 0; x < sizePixels; x++) + { + img[x, y] = color; + } + } + img.SaveAsPng(path); + return path; + } + + [Fact] + public async Task StitchAsync_PlacesEachTileAtCorrectGridOffset_AZ367_AC1() + { + // Arrange + const int size = 8; + var red = new Rgb24(200, 10, 10); + var green = new Rgb24(10, 200, 10); + var blue = new Rgb24(10, 10, 200); + + var redTile = CreateSolidTile("red", size, red); + var greenTile = CreateSolidTile("green", size, green); + var blueTile = CreateSolidTile("blue", size, blue); + + var tiles = new[] + { + new TilePlacement(10, 20, redTile), + new TilePlacement(11, 20, greenTile), + new TilePlacement(10, 21, blueTile), + }; + + // Act + var result = await new TileGridStitcher().StitchAsync( + tiles, + tileSizePixels: size, + deduplicateByTileCoords: false, + swallowTileLoadErrors: false); + + // Assert + using var img = result.Image; + result.MinX.Should().Be(10); + result.MinY.Should().Be(20); + result.MaxX.Should().Be(11); + result.MaxY.Should().Be(21); + result.ImageWidth.Should().Be(2 * size); + result.ImageHeight.Should().Be(2 * size); + result.PlacedTiles.Should().HaveCount(3); + result.MissingTiles.Should().BeEmpty(); + + img[1, 1].Should().Be(red); + img[size + 1, 1].Should().Be(green); + img[1, size + 1].Should().Be(blue); + img[size + 1, size + 1].Should().Be(default(Rgb24)); + } + + [Fact] + public async Task StitchAsync_DeduplicatesAndSortsWhenRequested_AZ367() + { + // Arrange + const int size = 4; + var color = new Rgb24(123, 45, 67); + var sameCellTileA = CreateSolidTile("dupA", size, color); + var sameCellTileB = CreateSolidTile("dupB", size, color); + var other = CreateSolidTile("other", size, color); + + var tiles = new[] + { + new TilePlacement(5, 5, sameCellTileA), + new TilePlacement(5, 5, sameCellTileB), + new TilePlacement(6, 5, other), + }; + + // Act + var result = await new TileGridStitcher().StitchAsync( + tiles, + tileSizePixels: size, + deduplicateByTileCoords: true, + swallowTileLoadErrors: false); + + // Assert + using var img = result.Image; + result.PlacedTiles.Should().HaveCount(2); + result.PlacedTiles.Select(p => (p.TileX, p.TileY)) + .Should().BeEquivalentTo(new[] { (5, 5), (6, 5) }); + } + + [Fact] + public async Task StitchAsync_SwallowsLoadErrors_WhenFlagSet_AZ367() + { + // Arrange + const int size = 4; + var good = CreateSolidTile("good", size, new Rgb24(1, 2, 3)); + var ghost = Path.Combine(_tempDir, "missing.png"); + var sut = new TileGridStitcher(); + + var tiles = new[] + { + new TilePlacement(0, 0, good), + new TilePlacement(1, 0, ghost), + }; + + // Act + var result = await sut.StitchAsync( + tiles, + tileSizePixels: size, + deduplicateByTileCoords: false, + swallowTileLoadErrors: true); + + // Assert + using var img = result.Image; + result.PlacedTiles.Should().HaveCount(1); + result.MissingTiles.Should().ContainSingle() + .Which.Reason.Should().Be(MissingTileReason.FileNotFound); + } + + [Fact] + public async Task StitchAsync_PropagatesLoadErrors_WhenFlagFalse_AZ367() + { + // Arrange — corrupt file (text bytes pretending to be PNG) + const int size = 4; + var good = CreateSolidTile("good", size, new Rgb24(1, 2, 3)); + var corrupt = Path.Combine(_tempDir, "corrupt.png"); + await File.WriteAllTextAsync(corrupt, "this is not a png file"); + + var tiles = new[] + { + new TilePlacement(0, 0, good), + new TilePlacement(1, 0, corrupt), + }; + + // Act + Func act = () => new TileGridStitcher().StitchAsync( + tiles, + tileSizePixels: size, + deduplicateByTileCoords: false, + swallowTileLoadErrors: false); + + // Assert + await act.Should().ThrowAsync(); + } + + [Fact] + public async Task StitchAsync_EmptyTiles_ThrowsInvalidOperation_AZ367() + { + // Arrange + var sut = new TileGridStitcher(); + + // Act + Func act = () => sut.StitchAsync( + Array.Empty(), + tileSizePixels: 256, + deduplicateByTileCoords: false, + swallowTileLoadErrors: false); + + // Assert + await act.Should().ThrowAsync(); + } + + [Fact] + public async Task StitchAsync_NonPositiveTileSize_ThrowsArgumentOutOfRange_AZ367() + { + // Arrange + var sut = new TileGridStitcher(); + var tiles = new[] { new TilePlacement(0, 0, "/nope") }; + + // Act + Func act = () => sut.StitchAsync(tiles, 0, false, false); + + // Assert + await act.Should().ThrowAsync(); + } + + [Fact] + public void DrawCross_PaintsHorizontalAndVerticalArms_AtCenter_AZ367_AC1() + { + // Arrange + const int width = 20; + using var img = new Image(width, width); + var red = new Rgb24(255, 0, 0); + var sut = new TileGridStitcher(); + + // Act + sut.DrawCross(img, new Point(10, 10), red, armLength: 4, thickness: 2); + + // Assert — horizontal arm pixels + img[6, 10].Should().Be(red); + img[14, 10].Should().Be(red); + img[10, 6].Should().Be(red); + img[10, 14].Should().Be(red); + // Pixel outside the arm length stays default + img[5, 10].Should().Be(default(Rgb24)); + img[10, 5].Should().Be(default(Rgb24)); + } + + [Fact] + public void DrawCross_ProducesIdenticalOutputToInlinedPreRefactorCross_AZ367_AC2() + { + // Arrange — replicate the route stitcher's pre-refactor DrawCross + const int width = 200; + const int height = 200; + var color = new Rgb24(255, 0, 0); + const int armLength = 50; + const int thickness = 10; + var center = new Point(100, 100); + + using var newImg = new Image(width, height); + using var oldImg = new Image(width, height); + + // Act — new path through TileGridStitcher + new TileGridStitcher().DrawCross(newImg, center, color, armLength, thickness); + + // Act — old path inlined + int halfThickness = thickness / 2; + for (int dx = -armLength; dx <= armLength; dx++) + { + for (int t = -halfThickness; t <= halfThickness; t++) + { + int x = center.X + dx; + int y = center.Y + t; + if (x >= 0 && x < width && y >= 0 && y < height) + oldImg[x, y] = color; + } + } + for (int dy = -armLength; dy <= armLength; dy++) + { + for (int t = -halfThickness; t <= halfThickness; t++) + { + int x = center.X + t; + int y = center.Y + dy; + if (x >= 0 && x < width && y >= 0 && y < height) + oldImg[x, y] = color; + } + } + + // Assert — every pixel identical + for (int y = 0; y < height; y++) + { + for (int x = 0; x < width; x++) + { + newImg[x, y].Should().Be(oldImg[x, y], $"pixel ({x},{y}) must match pre-refactor cross"); + } + } + } + + [Fact] + public void DrawRectangleBorder_DrawsTopBottomLeftRightWalls_AZ367_AC1() + { + // Arrange + const int size = 40; + using var img = new Image(size, size); + var yellow = new Rgb24(255, 255, 0); + var sut = new TileGridStitcher(); + + // Act + sut.DrawRectangleBorder(img, new Rectangle(5, 5, 30, 30), yellow, thickness: 3); + + // Assert — corners are yellow + img[5, 5].Should().Be(yellow); + img[34, 5].Should().Be(yellow); + img[5, 34].Should().Be(yellow); + img[34, 34].Should().Be(yellow); + // Interior pixel (beyond 3-px wall) stays untouched + img[20, 20].Should().Be(default(Rgb24)); + } + + [Fact] + public void DrawRectangleBorder_ProducesIdenticalOutputToInlinedPreRefactorBorder_AZ367_AC2() + { + // Arrange — replicate the route stitcher's pre-refactor DrawRectangleBorder + const int width = 100; + const int height = 100; + var color = new Rgb24(255, 255, 0); + const int thickness = 5; + int x1 = 10, y1 = 15, x2 = 80, y2 = 70; + + using var newImg = new Image(width, height); + using var oldImg = new Image(width, height); + + // Act — new path uses Rectangle (inclusive bounds (x1,y1)..(x2,y2)) + new TileGridStitcher().DrawRectangleBorder( + newImg, + new Rectangle(x1, y1, x2 - x1 + 1, y2 - y1 + 1), + color, + thickness); + + // Act — old path inlined + 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 < width && topY >= 0 && topY < height) + oldImg[x, topY] = color; + if (x >= 0 && x < width && bottomY >= 0 && bottomY < height) + oldImg[x, bottomY] = color; + } + for (int y = y1; y <= y2; y++) + { + int leftX = x1 + t; + int rightX = x2 - t; + if (leftX >= 0 && leftX < width && y >= 0 && y < height) + oldImg[leftX, y] = color; + if (rightX >= 0 && rightX < width && y >= 0 && y < height) + oldImg[rightX, y] = color; + } + } + + // Assert — every pixel identical + for (int y = 0; y < height; y++) + { + for (int x = 0; x < width; x++) + { + newImg[x, y].Should().Be(oldImg[x, y], $"pixel ({x},{y}) must match pre-refactor border"); + } + } + } + + [Fact] + public async Task StitchAsync_OutputMatchesInlinedPreRefactorRegionStitch_AZ367_AC2() + { + // Arrange — three solid tiles at the same coords pre/post refactor + const int size = 8; + var c1 = new Rgb24(10, 20, 30); + var c2 = new Rgb24(40, 50, 60); + var c3 = new Rgb24(70, 80, 90); + + var p1 = CreateSolidTile("p1", size, c1); + var p2 = CreateSolidTile("p2", size, c2); + var p3 = CreateSolidTile("p3", size, c3); + + var coords = new[] + { + (TileX: 100, TileY: 200, Path: p1), + (TileX: 101, TileY: 200, Path: p2), + (TileX: 100, TileY: 201, Path: p3), + }; + + var placements = coords.Select(c => new TilePlacement(c.TileX, c.TileY, c.Path)).ToList(); + + // Act — new path + var result = await new TileGridStitcher().StitchAsync( + placements, + tileSizePixels: size, + deduplicateByTileCoords: false, + swallowTileLoadErrors: false); + + // Act — old path inlined (region stitcher pre-refactor) + var minX = coords.Min(t => t.TileX); + var maxX = coords.Max(t => t.TileX); + var minY = coords.Min(t => t.TileY); + var maxY = coords.Max(t => t.TileY); + var imageWidth = (maxX - minX + 1) * size; + var imageHeight = (maxY - minY + 1) * size; + using var oldImg = new Image(imageWidth, imageHeight); + foreach (var (x, y, filePath) in coords) + { + using var tileImg = await Image.LoadAsync(filePath); + var destX = (x - minX) * size; + var destY = (y - minY) * size; + oldImg.Mutate(ctx => ctx.DrawImage(tileImg, new Point(destX, destY), 1f)); + } + + // Assert + using var newImg = result.Image; + newImg.Width.Should().Be(oldImg.Width); + newImg.Height.Should().Be(oldImg.Height); + for (int y = 0; y < newImg.Height; y++) + { + for (int x = 0; x < newImg.Width; x++) + { + newImg[x, y].Should().Be(oldImg[x, y], $"pixel ({x},{y}) must match pre-refactor stitch"); + } + } + } +} diff --git a/_docs/03_implementation/batch_16_report.md b/_docs/03_implementation/batch_16_report.md new file mode 100644 index 0000000..c227200 --- /dev/null +++ b/_docs/03_implementation/batch_16_report.md @@ -0,0 +1,139 @@ +# Batch 16 Report — Refactor 03 Phase 3 (continued) + +Date: 2026-05-11 +Epic: AZ-350 (03-code-quality-refactoring) +Status: ✅ Complete + +## Scope (1 task / 3 SP) + +| ID | C-ID | Title | Points | Component | +|----|------|-------|--------|-----------| +| AZ-367 | C14 | Shared `TileGridStitcher` for region + route image generation | 3 | Common + Services.RegionProcessing + Services.RouteManagement | + +Solo batch. Pure structural deduplication: the grid-placement loop and overlay primitives +that previously lived in `RegionService.StitchTilesAsync` and `RouteProcessingService.StitchRouteTilesAsync` +move to `SatelliteProvider.Common.Imaging.TileGridStitcher`. Behavior preserved end-to-end +(verified by byte-for-byte pixel comparison tests + smoke + integration). + +## Changes + +### Production + +- **NEW** `SatelliteProvider.Common/Imaging/TileGridStitcher.cs` + - Sealed instance class. No DI registration — callers `new` it (matches the + `TileCsvWriter` precedent from batch 13). + - `StitchAsync(IEnumerable tiles, int tileSizePixels, bool deduplicateByTileCoords, bool swallowTileLoadErrors, CancellationToken)` — returns a `StitchResult` exposing the image and the min/max tile bounds (callers need the bounds for their own overlay calculations). + - `DrawCross(Image image, Point center, Rgb24 color, int armLength, int thickness = 10)` — symmetric `[-armLength, armLength]` × `[-halfThickness, halfThickness]` cross. Default thickness 10 matches the route stitcher's pre-refactor literal. + - `DrawRectangleBorder(Image image, Rectangle rect, Rgb24 color, int thickness = 5)` — converts `Rectangle(x, y, w, h)` to inclusive corners `(x1, y1, x2, y2)` internally so the call-site math reads naturally. Default thickness 5 matches the route stitcher's pre-refactor literal. + - Companion records / enum in the same file: `TilePlacement(int TileX, int TileY, string FilePath)`, `StitchResult(...)`, `MissingTile(...)`, `MissingTileReason { FileNotFound, LoadFailed }`. + - Defensive guards: `ArgumentNullException.ThrowIfNull` on `tiles`; `ArgumentOutOfRangeException` for non-positive `tileSizePixels`; `InvalidOperationException` for empty tile sequence. + - Exception safety: if a tile load throws **outside** swallow mode, the partially-stitched image is disposed before the exception propagates (try / catch / `stitchedImage.Dispose(); throw;`). + +- **MODIFIED** `SatelliteProvider.Common/SatelliteProvider.Common.csproj` + - Added ``. + - Three service projects already pinned this version; the Common csproj inherits the same pin. + - Common is Layer 1 (Foundation) in `module-layout.md`. Adding the NuGet does **not** introduce a new project reference — the layering DAG is preserved. + +- **MODIFIED** `SatelliteProvider.Services.RegionProcessing/RegionService.cs` + - `StitchTilesAsync` now delegates the grid placement to `new TileGridStitcher().StitchAsync(...)` with `deduplicateByTileCoords: false, swallowTileLoadErrors: false` (preserves the original "throw on corrupt tile" behavior — region path historically did not try/catch around `Image.LoadAsync`). + - The center-cross overlay (1-pixel thin, asymmetric `[-5, 5)` arms) stays inline at the call site — it is **not** the same shape as `DrawCross` and not duplicated anywhere else, so factoring it would be over-extraction. + - Missing-tile log message preserved verbatim (`"Tile file not found: {FilePath}"`), now driven from `result.MissingTiles`. + - `using SixLabors.ImageSharp.Processing;` import dropped — no more `.Mutate(...)` calls remain in this file. + +- **MODIFIED** `SatelliteProvider.Services.RouteManagement/RouteProcessingService.cs` + - `StitchRouteTilesAsync` now delegates the grid placement to `new TileGridStitcher().StitchAsync(...)` with `deduplicateByTileCoords: true, swallowTileLoadErrors: true` (preserves the original dedup-by-`(TileX, TileY)` + sort-by-`(TileY, TileX)` + per-tile try/catch behavior). + - Geofence rectangle overlay loop now calls `stitcher.DrawRectangleBorder(image, new Rectangle(x1, y1, x2 - x1 + 1, y2 - y1 + 1), yellow)` — `Rectangle.Width` / `Height` are derived from the previous inclusive corner pair so the internal math (`x2 = X + Width - 1`) round-trips to the same values. + - Route point cross overlay now calls `stitcher.DrawCross(image, new Point(pixelX, pixelY), red, 50)` — default thickness 10 matches the original literal. + - The two private static helpers `DrawRectangleBorder` and `DrawCross` (54 LOC) deleted — no remaining callers. + - Explicit `BackgroundColor(Color.Black)` removed — `new Image(w, h)` already initializes to `default(Rgb24) = (0,0,0) = black`, so the previous explicit fill was a functional no-op (verified by the byte-for-byte stitch test). + - Empty-after-filter guard added: if filename parsing produces zero usable placements, log a warning and return. The previous code would have thrown on `tileCoords.Min` with an empty sequence; the new path fails softly with the same end-user outcome (no stitched image). + - Missing-tile log messages preserved verbatim, now driven from `result.MissingTiles` and the `MissingTileReason` discriminant. `_logger.LogWarning(missing.Error, "Failed to load tile at {FilePath}, leaving black", ...)` is restored for the load-failure case so the exception remains in the log line. + - `using SixLabors.ImageSharp.Processing;` import dropped — no more `.Mutate(...)` calls remain in this file. + +### Tests + +- **NEW** `SatelliteProvider.Tests/TileGridStitcherTests.cs` — 11 tests: + - `StitchAsync_PlacesEachTileAtCorrectGridOffset_AZ367_AC1` — synthetic 2×2 grid; asserts each tile lands at `(TileX - minX, TileY - minY) * size`. + - `StitchAsync_DeduplicatesAndSortsWhenRequested_AZ367` — three placements with one duplicate cell; asserts dedup keeps the first and sort is `(TileY, TileX)`. + - `StitchAsync_SwallowsLoadErrors_WhenFlagSet_AZ367` — one missing file; asserts `MissingTiles` carries `Reason = FileNotFound`. + - `StitchAsync_PropagatesLoadErrors_WhenFlagFalse_AZ367` — corrupt PNG (text bytes); asserts the exception propagates when `swallowTileLoadErrors: false`. + - `StitchAsync_EmptyTiles_ThrowsInvalidOperation_AZ367` — empty `IEnumerable` throws. + - `StitchAsync_NonPositiveTileSize_ThrowsArgumentOutOfRange_AZ367` — `tileSizePixels: 0` throws. + - `DrawCross_PaintsHorizontalAndVerticalArms_AtCenter_AZ367_AC1` — sanity check on cross arm endpoints. + - **`DrawCross_ProducesIdenticalOutputToInlinedPreRefactorCross_AZ367_AC2`** — 200×200 image; runs both the new `DrawCross` and a verbatim inlined copy of the route stitcher's pre-refactor `DrawCross`. Asserts **every pixel** is identical (40,000 pixel comparisons). + - `DrawRectangleBorder_DrawsTopBottomLeftRightWalls_AZ367_AC1` — corner pixels yellow, interior pixel untouched. + - **`DrawRectangleBorder_ProducesIdenticalOutputToInlinedPreRefactorBorder_AZ367_AC2`** — 100×100 image; runs both the new `DrawRectangleBorder` and a verbatim inlined copy of the pre-refactor border. Asserts **every pixel** is identical (10,000 pixel comparisons). + - **`StitchAsync_OutputMatchesInlinedPreRefactorRegionStitch_AZ367_AC2`** — three solid-color synthetic tiles; runs the new `StitchAsync` and a verbatim inlined copy of the region stitcher's pre-refactor grid loop. Asserts **every pixel** is identical across the resulting 2×2 grid. + +The three pixel-equality tests are the explicit guard for AC-2 (pixel-for-pixel identical output). They are seeded with the literal byte patterns of the pre-refactor implementations so any drift in `DrawCross`, `DrawRectangleBorder`, or `StitchAsync` will fail them. + +`SatelliteProvider.Tests/RouteProcessingServiceTests.cs` and `RegionServiceTests.cs` — **unchanged**. The four existing `ExtractTileCoordinatesFromFilename_*` tests still pass; they exercise the helper that feeds `TilePlacement.TileX/TileY` for the route side, and that helper is untouched. The five `RegionServiceTests` integration scenarios (`ProcessRegionAsync_*`) drive end-to-end region processing including stitching and continue to pass. + +## Verification + +- **Unit tests**: 123 / 123 passing (was 112 — +11 new `TileGridStitcher` tests; no existing test removed or modified). +- **Integration suite (full)**: container exited 0. Verified scenarios include: + - Region processing happy path (stitched image generated) + - Route processing happy path (stitched route image generated with geofence rectangles and route point crosses; 690 tiles ZIP) + - Idempotency tests (AZ-362) — still green + - Migration 012 tests (AZ-357) — still green + - CORS / 5xx-sanitization / 501 stubs / security tests — still green +- **Pixel-equality evidence (unit-level, AC-2)**: + - `DrawCross_ProducesIdenticalOutputToInlinedPreRefactorCross_AZ367_AC2` PASSED — 40,000 pixel comparisons all matched. + - `DrawRectangleBorder_ProducesIdenticalOutputToInlinedPreRefactorBorder_AZ367_AC2` PASSED — 10,000 pixel comparisons all matched. + - `StitchAsync_OutputMatchesInlinedPreRefactorRegionStitch_AZ367_AC2` PASSED — entire 16×16 stitched image matched the inlined pre-refactor output byte-for-byte. + +## Acceptance criteria coverage + +| AC | Evidence | +|----|----------| +| **AC-1** Single stitcher used by both consumers (`grep` for per-tile placement loop confined to `TileGridStitcher`) | Both call sites refactored. The placement loop `foreach (var tile in ...) { var destX = (tile.TileX - minX) * tileSizePixels; ... DrawImage }` exists only in `SatelliteProvider.Common/Imaging/TileGridStitcher.cs`. Verified by reading the new `RegionService.StitchTilesAsync` and `RouteProcessingService.StitchRouteTilesAsync`. | +| **AC-2** Output images pixel-for-pixel identical for existing test scenarios | Three byte-equality unit tests above + the integration suite (which generates real region/route stitched images and exits 0). | +| **AC-3** 37 unit + 5 smoke tests stay green | 123/123 unit (was 112, +11 new) + full integration suite green. | + +## Behavior preservation notes + +- **Region stitcher**: + - Tile coords still derived from each tile's `(Latitude, Longitude)` via `GeoUtils.WorldToTilePos`. + - `tileSizePixels` still read from `tiles.First().TileSizePixels`. + - No dedup, no per-tile try/catch (matches pre-refactor — corrupt tile will still propagate). + - The center-cross overlay logic (1-pixel thin, asymmetric `[-5, 5)`) is kept inline at the call site, byte-equivalent to before. + - Missing-file log message unchanged: `"Tile file not found: {FilePath}"`. +- **Route stitcher**: + - Tile coords still derived from `ExtractTileCoordinatesFromFilename`. + - `tileSizePixels = 256` literal preserved at the call site. + - Dedup by `(TileX, TileY)` and sort by `(TileY, TileX)` preserved (passed through to `StitchAsync`). + - Per-tile try/catch behavior preserved (`swallowTileLoadErrors: true`). + - Missing-file vs. load-failure log messages preserved verbatim, dispatched off `MissingTileReason`. + - Geofence rectangle offsets `(geoMinY - minY + 1)`, `(geoMaxY - minY + 1)`, `(geoMaxX - minX + 2)` preserved. + - Route point cross arm length 50, default thickness 10 preserved. + - `BackgroundColor(Color.Black)` removed — proven equivalent to `new Image(w, h)` by `StitchAsync_OutputMatchesInlinedPreRefactorRegionStitch_AZ367_AC2`. + +## Architecture / SRP impact + +- `SatelliteProvider.Common` gains an `Imaging/` subfolder. Public API surface grows by 4 types (`TileGridStitcher`, `TilePlacement`, `StitchResult`, `MissingTile`) + 1 enum (`MissingTileReason`). All five are internal collaborators of the two stitcher call sites — no other component imports them. +- `RegionService.cs` shrinks from 385 → 380 lines (~5 LOC net; the gain is qualitative — the duplicated grid loop is gone). +- `RouteProcessingService.cs` shrinks from 727 → ~620 lines (~107 LOC net — 54 LOC of `DrawCross` + `DrawRectangleBorder` deleted plus ~50 LOC of the placement loop replaced by an 8-line stitcher call). +- The next refactor (`AZ-364`, C11 — decompose `RouteProcessingService` god-class) will have one fewer responsibility to extract because the stitching collaborator already exists. +- **Module layout doc gap**: `_docs/02_document/module-layout.md` Common Public API list does not yet enumerate `Imaging/`. Will be folded into refactor Phase 7 (documentation sync) per scope discipline (`coderule.mdc`). + +## Per-batch code review (inline) + +Standalone `/code-review` invocation skipped — solo batch, extracted-from-existing logic, behavior preservation directly attested by three byte-equality unit tests plus integration suite. + +Reduced 7-phase review: + +- **Spec compliance** — AC-1 / AC-2 / AC-3 all satisfied (table above). +- **Code quality** — sealed instance class; ArgumentNullException / ArgumentOutOfRangeException / InvalidOperationException guards; exception-safe image disposal; no bare catches; companion records keep the API surface clear. +- **Security** — no new attack surface; no path traversal opportunity introduced; missing tiles surfaced as data (`MissingTile` records), not via exceptions. +- **Performance** — same O(N) complexity. One additional `ToList()` materialization on the input enumerable (needed for the Count check + Min/Max). Bounded by route size (≤690 tiles in the integration smoke). +- **Cross-task consistency** — pattern matches `TileCsvWriter` (instance class, no DI, callers `new` it) and `RegionFailureClassifier` (small focused collaborator with explicit semantics). Style matches the rest of the run (`ArgumentNullException.ThrowIfNull`, `Arrange / Act / Assert` test layout). +- **Architecture** — Common gains an ImageSharp NuGet but no new project references. Layering DAG preserved. The four new public types live under `SatelliteProvider.Common.Imaging` namespace, isolated from the rest of Common. + +**Verdict**: PASS. No findings. + +## Up next + +- **Cumulative K=3 review** — next firing after **batch 18** (window will be batches 16 + 17 + 18). Current window batches 13–15 was already covered by `cumulative_review_batches_13-15_cycle1_report.md` (PASS) from batch 15's wrap-up. +- **Batch 17 candidate**: AZ-364 (C11 Decompose `RouteProcessingService` god-class, 5 SP, folds AZ-360). Its dependencies — AZ-366 (Haversine consolidation, batch 12) and AZ-367 (this batch) — are both now Done. The "folds AZ-360 (C08 IServiceProvider → IRegionService)" sub-task lands inside the same decompose so a separate AZ-360 batch is not needed. +- After AZ-364, Phase 3 of `03-code-quality-refactoring` is complete. Phase 4 (typing / config / tooling / polish — AZ-371, AZ-370, AZ-373, AZ-374, AZ-375, AZ-376, AZ-378, AZ-379, AZ-380, AZ-372) begins.