mirror of
https://github.com/azaion/satellite-provider.git
synced 2026-06-21 11:21:13 +00:00
10d31b4c1c
Co-authored-by: Cursor <cursoragent@cursor.com>
214 lines
6.7 KiB
C#
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
|
|
}
|