using Microsoft.Extensions.Caching.Memory; using SixLabors.ImageSharp; using SixLabors.ImageSharp.PixelFormats; using SixLabors.ImageSharp.Processing; using SixLabors.ImageSharp.Formats.Png; namespace LiloStitcher; public record GenerateRequest( string CanvasRect, double[] CropOffset, double[] CropSize, double OutputScale ); public readonly record struct PlateCoordinate(int Row, int Col) { public static PlateCoordinate Parse(string token) { var rowPart = new string(token.TakeWhile(char.IsLetter).ToArray()).ToUpperInvariant(); var colPart = new string(token.SkipWhile(char.IsLetter).ToArray()); int row = 0; foreach (var character in rowPart) row = row * 26 + (character - 'A' + 1); int.TryParse(colPart, out int col); return new PlateCoordinate(row, col); } } public class TileCache(IMemoryCache cache) { private const long TileBytes = 720L * 720 * 3; public Image? Get(string key) => cache.TryGetValue(key, out Image? img) ? img : null; public void Set(string key, Image img) => cache.Set(key, img, new MemoryCacheEntryOptions { Size = TileBytes, SlidingExpiration = TimeSpan.FromMinutes(20) }); } public class TileLoader(TileCache cache, string assetDir) { public async Task> LoadAsync(string name, CancellationToken ct) { if (cache.Get(name) is { } hit) return hit; var path = Path.Combine(assetDir, $"{name}.png"); await using var fs = File.OpenRead(path); var image = await Image.LoadAsync(fs, ct).ConfigureAwait(false); cache.Set(name, image); return image; } } public class LiloStitcher(TileLoader loader) { private const int TileSize = 720; private static readonly GraphicsOptions _copy = new() { Antialias = false, AlphaCompositionMode = PixelAlphaCompositionMode.Src, BlendPercentage = 1f }; public async Task CreateImageAsync(GenerateRequest req, CancellationToken ct) { var parts = req.CanvasRect.ToUpperInvariant().Split(':', StringSplitOptions.TrimEntries | StringSplitOptions.RemoveEmptyEntries); var part1 = PlateCoordinate.Parse(parts[0]); var part2 = PlateCoordinate.Parse(parts[1]); int rowMin = Math.Min(part1.Row, part2.Row); int rowMax = Math.Max(part1.Row, part2.Row); int colMin = Math.Min(part1.Col, part2.Col); int colMax = Math.Max(part1.Col, part2.Col); int rows = rowMax - rowMin + 1; int cols = colMax - colMin + 1; var names = Enumerable.Range(rowMin, rows) .SelectMany(r => Enumerable.Range(colMin, cols).Select(c => $"{RowName(r)}{c}")) .ToArray(); var bitmaps = await Task.WhenAll(names.Select(n => loader.LoadAsync(n, ct))).ConfigureAwait(false); var stitched = new Image(cols * TileSize, rows * TileSize); stitched.Mutate(context => { int idx = 0; for (int row = 0; row < rows; row++) { for (int col = 0; col < cols; col++, idx++) { context.DrawImage(bitmaps[idx], new Point(col * TileSize, row * TileSize), 1f); } } }); Validate(req.CropOffset, 2, nameof(req.CropOffset), inclusiveUpper: true); Validate(req.CropSize, 2, nameof(req.CropSize), inclusiveUpper: true); int offsetX = (int)Math.Truncate(req.CropOffset[0] * stitched.Width); int offsetY = (int)Math.Truncate(req.CropOffset[1] * stitched.Height); int restWidth = stitched.Width - offsetX; int restHeight = stitched.Height - offsetY; int cropWidth = Math.Max(1, (int)Math.Truncate(req.CropSize[0] * restWidth )); int cropHeight = Math.Max(1, (int)Math.Truncate(req.CropSize[1] * restHeight)); int cropX = (int)Math.Truncate(offsetX / 2.0 + (restWidth - cropWidth) / 2.0); int cropY = (int)Math.Truncate(offsetY / 2.0 + (restHeight - cropHeight) / 2.0); var cropRect = new Rectangle(cropX, cropY, cropWidth, cropHeight); using var cropped = stitched.Clone(context => context.Crop(cropRect)); double scale = Math.Clamp(req.OutputScale, 0.0, 1.0); if (scale <= 0 || scale > 1) throw new ArgumentOutOfRangeException(nameof(req.OutputScale), "OutputScale must be > 0 and ≤ 1.0"); Image output; if (scale < 1.0) { int width = Math.Max(1, (int)Math.Truncate(cropWidth * scale)); int height = Math.Max(1, (int)Math.Truncate(cropHeight * scale)); output = cropped.Clone(ctx => ctx.Resize(width, height)); } else { output = cropped.Clone(); } using var memStream = new MemoryStream(); await output.SaveAsync(memStream, new PngEncoder { CompressionLevel = PngCompressionLevel.Level1 }, ct).ConfigureAwait(false); return memStream.ToArray(); } static void Validate(double[] arr, int len, string name, bool inclusiveUpper) { if (arr is null || arr.Length < len) throw new ArgumentException($"{name} must have length {len}"); double upper = inclusiveUpper ? 1.0 : 1.0 - double.Epsilon; for (int i = 0; i < len; i++) { var v = arr[i]; if (v < 0 || v > upper) throw new ArgumentOutOfRangeException($"{name}[{i}]= {v} outside [0,{upper}]"); } } static string RowName(int row) { var stringBuilder = new System.Text.StringBuilder(); while (row > 0) { row--; stringBuilder.Insert(0, (char)('A' + row % 26)); row /= 26; } return stringBuilder.ToString(); } }