mirror of
https://github.com/azaion/satellite-provider.git
synced 2026-06-21 05:41:14 +00:00
[AZ-367] Refactor C14: extract shared TileGridStitcher
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -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<Rgb24>, 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<StitchResult> StitchAsync(
|
||||
IEnumerable<TilePlacement> 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<Rgb24>(imageWidth, imageHeight);
|
||||
var placed = new List<TilePlacement>(orderedTiles.Count);
|
||||
var missing = new List<MissingTile>();
|
||||
|
||||
try
|
||||
{
|
||||
foreach (var tile in orderedTiles)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
|
||||
if (!File.Exists(tile.FilePath))
|
||||
{
|
||||
missing.Add(new MissingTile(tile, MissingTileReason.FileNotFound, Error: null));
|
||||
continue;
|
||||
}
|
||||
|
||||
Image<Rgb24>? tileImage = null;
|
||||
try
|
||||
{
|
||||
tileImage = await Image.LoadAsync<Rgb24>(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<Rgb24> 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<Rgb24> 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<Rgb24> Image,
|
||||
int MinX,
|
||||
int MinY,
|
||||
int MaxX,
|
||||
int MaxY,
|
||||
int TileSizePixels,
|
||||
int ImageWidth,
|
||||
int ImageHeight,
|
||||
IReadOnlyList<TilePlacement> PlacedTiles,
|
||||
IReadOnlyList<MissingTile> MissingTiles);
|
||||
|
||||
public sealed record MissingTile(TilePlacement Tile, MissingTileReason Reason, Exception? Error);
|
||||
|
||||
public enum MissingTileReason
|
||||
{
|
||||
FileNotFound,
|
||||
LoadFailed
|
||||
}
|
||||
@@ -6,4 +6,8 @@
|
||||
<Nullable>enable</Nullable>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="SixLabors.ImageSharp" Version="3.1.11" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
|
||||
@@ -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<Rgb24>(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<Rgb24>(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);
|
||||
|
||||
|
||||
@@ -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<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)
|
||||
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<Rgb24>(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<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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private Task CreateTilesZipAsync(
|
||||
string zipFilePath,
|
||||
IEnumerable<TileInfo> tiles,
|
||||
|
||||
@@ -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<Rgb24>(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<Task> act = () => new TileGridStitcher().StitchAsync(
|
||||
tiles,
|
||||
tileSizePixels: size,
|
||||
deduplicateByTileCoords: false,
|
||||
swallowTileLoadErrors: false);
|
||||
|
||||
// Assert
|
||||
await act.Should().ThrowAsync<Exception>();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task StitchAsync_EmptyTiles_ThrowsInvalidOperation_AZ367()
|
||||
{
|
||||
// Arrange
|
||||
var sut = new TileGridStitcher();
|
||||
|
||||
// Act
|
||||
Func<Task> act = () => sut.StitchAsync(
|
||||
Array.Empty<TilePlacement>(),
|
||||
tileSizePixels: 256,
|
||||
deduplicateByTileCoords: false,
|
||||
swallowTileLoadErrors: false);
|
||||
|
||||
// Assert
|
||||
await act.Should().ThrowAsync<InvalidOperationException>();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task StitchAsync_NonPositiveTileSize_ThrowsArgumentOutOfRange_AZ367()
|
||||
{
|
||||
// Arrange
|
||||
var sut = new TileGridStitcher();
|
||||
var tiles = new[] { new TilePlacement(0, 0, "/nope") };
|
||||
|
||||
// Act
|
||||
Func<Task> act = () => sut.StitchAsync(tiles, 0, false, false);
|
||||
|
||||
// Assert
|
||||
await act.Should().ThrowAsync<ArgumentOutOfRangeException>();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DrawCross_PaintsHorizontalAndVerticalArms_AtCenter_AZ367_AC1()
|
||||
{
|
||||
// Arrange
|
||||
const int width = 20;
|
||||
using var img = new Image<Rgb24>(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<Rgb24>(width, height);
|
||||
using var oldImg = new Image<Rgb24>(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<Rgb24>(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<Rgb24>(width, height);
|
||||
using var oldImg = new Image<Rgb24>(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<Rgb24>(imageWidth, imageHeight);
|
||||
foreach (var (x, y, filePath) in coords)
|
||||
{
|
||||
using var tileImg = await Image.LoadAsync<Rgb24>(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");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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<TilePlacement> 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<Rgb24> 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<Rgb24> 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 `<PackageReference Include="SixLabors.ImageSharp" Version="3.1.11" />`.
|
||||
- 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<Rgb24>(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<TilePlacement>` 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<Rgb24>(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.
|
||||
Reference in New Issue
Block a user