From efaaee3b1778ffc62ec3823cf5181c0d82a129ae Mon Sep 17 00:00:00 2001 From: Bahru Date: Sat, 19 Jul 2025 17:44:08 +0700 Subject: [PATCH] first commit: push the working console project --- .gitignore | 2 + LiloStitcher.cs | 148 +++++++++++++++++++++++++++++++++++ Program.cs | 55 +++++++++++++ lilo-stitcher-console.csproj | 17 ++++ lilo-stitcher-console.sln | 24 ++++++ 5 files changed, 246 insertions(+) create mode 100644 .gitignore create mode 100644 LiloStitcher.cs create mode 100644 Program.cs create mode 100644 lilo-stitcher-console.csproj create mode 100644 lilo-stitcher-console.sln diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..8d4a6c0 --- /dev/null +++ b/.gitignore @@ -0,0 +1,2 @@ +bin +obj \ No newline at end of file diff --git a/LiloStitcher.cs b/LiloStitcher.cs new file mode 100644 index 0000000..ef4eb65 --- /dev/null +++ b/LiloStitcher.cs @@ -0,0 +1,148 @@ +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(); + } + } +} \ No newline at end of file diff --git a/Program.cs b/Program.cs new file mode 100644 index 0000000..71da835 --- /dev/null +++ b/Program.cs @@ -0,0 +1,55 @@ + +using Microsoft.Extensions.Caching.Memory; + +namespace LiloStitcher; +public static class Program +{ + public static async Task Main( string[] args ) + { + try + { + var opt = new Options(); + opt.CanvasRect = "A1:H12"; + opt.CropOffset = [0.25, 0.25]; + opt.CropSize = [0.75, 0.75]; + opt.OutputScale = 0.5; + opt.OutputPath = "stitched.png"; + + string tileFilePath = "../stitch-a-ton/tiles1705"; + string assetDir = Path.GetFullPath( Path.Combine( Directory.GetCurrentDirectory(), tileFilePath ) ); + + using var memCache = new MemoryCache( new MemoryCacheOptions { SizeLimit = 256L * 1024 * 1024 } ); + var tileCache = new TileCache( memCache ); + var loader = new TileLoader( tileCache, assetDir ); + var stitcher = new LiloStitcher( loader ); + + var req = new GenerateRequest( + opt.CanvasRect!, + opt.CropOffset!, + opt.CropSize!, + opt.OutputScale + ); + + Console.WriteLine( "Stitching..." ); + var png = await stitcher.CreateImageAsync( req, CancellationToken.None ); + + File.WriteAllBytes( opt.OutputPath!, png ); + Console.WriteLine( $"Done. Wrote {opt.OutputPath} ({png.Length / 1024.0:F1} KB)" ); + return 0; + } + catch( Exception ex ) + { + Console.Error.WriteLine( "ERROR: " + ex.Message ); + return 1; + } + } + + private struct Options + { + public string? CanvasRect { get; set; } + public double[]? CropOffset { get; set; } + public double[]? CropSize { get; set; } + public double OutputScale { get; set; } + public string? OutputPath { get; set; } + } +} \ No newline at end of file diff --git a/lilo-stitcher-console.csproj b/lilo-stitcher-console.csproj new file mode 100644 index 0000000..09af0f4 --- /dev/null +++ b/lilo-stitcher-console.csproj @@ -0,0 +1,17 @@ + + + + Exe + net9.0 + lilo_stitcher_console + enable + enable + + + + + + + + + diff --git a/lilo-stitcher-console.sln b/lilo-stitcher-console.sln new file mode 100644 index 0000000..2e5c9ca --- /dev/null +++ b/lilo-stitcher-console.sln @@ -0,0 +1,24 @@ +Microsoft Visual Studio Solution File, Format Version 12.00 +# Visual Studio Version 17 +VisualStudioVersion = 17.5.2.0 +MinimumVisualStudioVersion = 10.0.40219.1 +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "lilo-stitcher-console", "lilo-stitcher-console.csproj", "{2A1F81C9-D10F-1AE9-CA5C-0714270F48C1}" +EndProject +Global + GlobalSection(SolutionConfigurationPlatforms) = preSolution + Debug|Any CPU = Debug|Any CPU + Release|Any CPU = Release|Any CPU + EndGlobalSection + GlobalSection(ProjectConfigurationPlatforms) = postSolution + {2A1F81C9-D10F-1AE9-CA5C-0714270F48C1}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {2A1F81C9-D10F-1AE9-CA5C-0714270F48C1}.Debug|Any CPU.Build.0 = Debug|Any CPU + {2A1F81C9-D10F-1AE9-CA5C-0714270F48C1}.Release|Any CPU.ActiveCfg = Release|Any CPU + {2A1F81C9-D10F-1AE9-CA5C-0714270F48C1}.Release|Any CPU.Build.0 = Release|Any CPU + EndGlobalSection + GlobalSection(SolutionProperties) = preSolution + HideSolutionNode = FALSE + EndGlobalSection + GlobalSection(ExtensibilityGlobals) = postSolution + SolutionGuid = {C7CD65D7-8035-4FC2-A584-06B21F732FF7} + EndGlobalSection +EndGlobal