using Microsoft.Extensions.Caching.Memory; using NetVips; namespace LiloStitcher; public sealed record GenerateRequest( string Canvas_Rect, double[] Crop_Offset, double[] Crop_Size, double Output_Scale ); public readonly record struct PlateCoordinate( int Row, int Col ) { public static PlateCoordinate Parse( string token ) { if( string.IsNullOrWhiteSpace( token ) ) throw new ArgumentException( "Empty coordinate." ); var rowPart = new string( token.TakeWhile( char.IsLetter ).ToArray() ).ToUpperInvariant(); var colPart = new string( token.SkipWhile( char.IsLetter ).ToArray() ); int row = 0; foreach( char c in rowPart ) row = row * 26 + ( c - 'A' + 1 ); return new PlateCoordinate( row, int.Parse( colPart ) ); } } 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.Canvas_Rect ); double scale = req.Output_Scale; if( scale <= 0 || scale > 1 ) throw new ArgumentOutOfRangeException( nameof( req.Output_Scale ) ); 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.Output_Scale * mosaic.Width ); int offsetY = (int)Math.Truncate( req.Output_Scale * mosaic.Height ); int restWidth = mosaic.Width - offsetX; int restHeight = mosaic.Height - offsetY; int cropWidth = Math.Max( 1, (int)Math.Truncate( req.Crop_Size[0] * restWidth ) ); int cropHeight = Math.Max( 1, (int)Math.Truncate( req.Crop_Size[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 cropped = mosaic.Crop( cropX, cropY, cropWidth, cropHeight ); var finalImg = scale < 1.0 ? cropped.Resize( scale ) : cropped; string tmp = Path.GetTempFileName() + ".png"; finalImg.WriteToFile( tmp ); byte[] result = await File.ReadAllBytesAsync( tmp, ct ); File.Delete( tmp ); return result; } 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(); } }