using Microsoft.Extensions.Caching.Memory; using SkiaSharp; namespace LiloStitcher; public record GenerateRequest( string CanvasRect, double[] CropOffset, double[] CropSize, double OutputScale ); public 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 c in rowPart ) { row = row * 26 + ( c - 'A' + 1 ); } int.TryParse( colPart, out int col ); return new PlateCoordinate( row, col ); } } public class TileCache( IMemoryCache cache ) { private const long TileBytes = 720L * 720 * 4; public SKBitmap? Get( string key ) => cache.TryGetValue( key, out SKBitmap? bmp ) ? bmp : null; public void Set( string key, SKBitmap bmp ) => cache.Set( key, bmp, 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" ); var bytes = await File.ReadAllBytesAsync( path, ct ).ConfigureAwait( false ); using var data = SKData.CreateCopy( bytes ); var bmp = SKBitmap.Decode( data ); cache.Set( name, bmp ); return bmp; } } public class LiloStitcher( TileLoader loader ) { private const int TileSize = 720; 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 fullInfo = new SKImageInfo( cols * TileSize, rows * TileSize ); using var surface = SKSurface.Create( fullInfo ); var canvas = surface.Canvas; int idx = 0; for( int row = 0; row < rows; row++ ) for( int column = 0; column < cols; column++, idx++ ) canvas.DrawBitmap( bitmaps[idx], column * TileSize, row * TileSize ); using var full = new SKBitmap( fullInfo ); surface.ReadPixels( fullInfo, full.GetPixels(), full.RowBytes, 0, 0 ); ValidateFraction( req.CropOffset, 2, nameof( req.CropOffset ), inclusiveUpper: true ); ValidateFraction( req.CropSize, 2, nameof( req.CropSize ), inclusiveUpper: true ); int offsetX = (int)( req.CropOffset[0] * fullInfo.Width ); int offsetY = (int)( req.CropOffset[1] * fullInfo.Height ); int cropWidth = (int)( req.CropSize[0] * fullInfo.Width ); int cropHeight = (int)( req.CropSize[1] * fullInfo.Height ); if( offsetX + cropWidth > fullInfo.Width ) cropWidth = fullInfo.Width - offsetX; if( offsetY + cropHeight > fullInfo.Height ) cropHeight = fullInfo.Height - offsetY; var cropRect = new SKRectI( offsetX, offsetY, offsetX + cropWidth, offsetY + cropHeight ); using var crop = new SKBitmap( cropWidth, cropHeight ); full.ExtractSubset( crop, cropRect ); double scale = Math.Clamp( req.OutputScale, 0.0, 1.0 ); int truncatedWidth = Math.Max( 1, (int)Math.Round( cropWidth * scale ) ); int truncatedHeight = Math.Max( 1, (int)Math.Round( cropHeight * scale ) ); using var finalBmp = scale < 1.0 ? crop.Resize( new SKSizeI( truncatedWidth, truncatedHeight ), new SKSamplingOptions( SKFilterMode.Linear ) ) : crop; using var image = SKImage.FromBitmap( finalBmp ); using var data = image.Encode( SKEncodedImageFormat.Png, 100 ); return data.ToArray(); static void ValidateFraction( double[] arr, int len, string name, bool inclusiveUpper ) { for( int i = 0; i < len; i++ ) { double v = arr[i]; double upper = inclusiveUpper ? 1.0 : 1.0 - double.Epsilon; } } 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(); } } }