Files
satellite-provider/SatelliteProvider.Common/Imaging/TileGridStitcher.cs
T
Oleksandr Bezdieniezhnykh 10d31b4c1c [AZ-367] Refactor C14: extract shared TileGridStitcher
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-11 02:55:25 +03:00

214 lines
6.7 KiB
C#

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
}