using Microsoft.Extensions.Caching.Memory; using NetVips; namespace lilo_stitcher_console; 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 image = await Task.Run(() => Image.NewFromFile(Path.Combine(assetDir, $"{name}.png"), access: Enums.Access.Sequential), ct); cache.Set(name, image); return image; } } public class LiloStitcher(TileLoader loader) { public async Task CreateImageAsync(GenerateRequest req, CancellationToken ct) { (int rowMin, int colMin, int rows, int cols) = ParseCanvas(req.CanvasRect); Validate(req.CropOffset, nameof(req.CropOffset)); Validate(req.CropSize, nameof(req.CropSize)); double scale = req.OutputScale; if (scale <= 0 || scale > 1) throw new ArgumentOutOfRangeException(nameof(req.OutputScale)); var tiles = new List(rows * cols); for( int row = 0; row < rows; row++ ) { for( int col = 0; col < cols; col++ ) { string id = $"{RowName( rowMin + row )}{colMin + col}"; var tile = await loader.LoadAsync( id, ct ); if( scale < 1 ) tile = tile.Resize( scale ); tiles.Add( tile ); } } var mosaic = Image.Arrayjoin(tiles.ToArray(), across: cols); int offsetX = (int)Math.Truncate(req.CropOffset[0] * mosaic.Width); int offsetY = (int)Math.Truncate(req.CropOffset[1] * mosaic.Height); int restWidth = mosaic.Width - offsetX; int restHeight = mosaic.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 = mosaic.Crop(cropX, cropY, cropWidth, cropHeight); string tmpPath = Path.Combine(Path.GetTempPath(), $"mosaic-{Guid.NewGuid():N}.png"); cropRect.WriteToFile(tmpPath); return tmpPath; } private static (int rowMin, int colMin, int rows, int cols) ParseCanvas(string rect) { var parts = rect.ToUpperInvariant().Split(':', 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); return (rowMin, colMin, rowMax - rowMin + 1, colMax - colMin + 1); } private static void Validate(double[] arr, string name) { if (arr is null || arr.Length < 2) throw new ArgumentException($"{name} length"); if (arr.Any(x => x < 0 || x > 1)) throw new ArgumentOutOfRangeException(name); } 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(); } }