From db3c833c4c3debc7cc68148164d5c83340443fc4 Mon Sep 17 00:00:00 2001 From: mbsbahru Date: Mon, 21 Jul 2025 00:08:32 +0700 Subject: [PATCH] refactor into using ImageSharp --- .DS_Store | Bin 0 -> 8196 bytes .gitignore | 4 +- LiloStitcher.cs | 183 ++++++++++++++++++----------------- Program.cs | 46 +++++---- lilo-stitcher-console.csproj | 3 +- 5 files changed, 125 insertions(+), 111 deletions(-) create mode 100644 .DS_Store diff --git a/.DS_Store b/.DS_Store new file mode 100644 index 0000000000000000000000000000000000000000..e12bae7afa8363e2a28ad36d43dbcfaef11060ef GIT binary patch literal 8196 zcmeHMX^a#_6t361u-!sK?Ex&%K(iwVvMa-Kxg2J8XW6h^i#rQDd%(~$-9k%G_b}Zv zgB-HNBO*}~jmF^dOX8LA%L6t3Flh9L8iOmw_{S?I#vdAu`Ui}!x_XE#j=#)`u`8*1 zRj=yR>-YM7RsCiNA<$9Kml4uP2obR|N+)6S7lqBUdQ}kuPBl_Mc|r=rBPpyVv94Mj zGC~i89tb@UdLZ;b=z(j&19)e%B38Kf`E1yR9tb_~zw`h*A5^T2#sWIRseg1}f zSqk76>|-9_%cud31$2Z{UljM`*#iWk5TqEu#c^I`%aO(cI>IRzC&0xC1ezhpP{7xY za;m?aK#bF{4LuNg;A#&r_)I2_pSkC*$lnd(rb})vZ8>RNb2go`OxKG>FMv>0Jz-)E zmPs|c68oG|!qdEh+o9!$*r?BOtaQ1rrFWV}DMcINj_qltZRC9cQ#WwQaKCBmPN~Bw z*t+Y>CisMeB}E$#A70+Nx>;SZy6tGQdbq7Miv8uQjvkf7X_2LCdsF+ZtnJ*-O@iQ9 zAo3U!PyU^KmRpQ-`>N7q`#uls6Q%1WO_rx9$${kH(6F4MbwxC#C+C@t?d~-4F47d8 z>Y~Zr8OPq9H`siC#<2=n+fC8hjAmsDmgX7lmX$CM8SbEVu+KQ?u|5;bb}?zkq~ zaUkhcno173PTq_A`zB3P#W$b@*XT8Hf3s+Gq87JdjO8GmncAo>SbWp+o7cB@ZQ9m5 zN^9%t8>Y$fAiCN1%-c*OzoTU8-o63N%^0?B+I#zprfa4xqtDi|MlCc;(&)@tv#Byi znOB|~D=8S^%8wq9MBu%p97`cy}BB#k2@)kKyJ|K9)Rl3L}v$TtaK`|V^77p2P-RV-3X_?2sZx7h}2KVkzW485c?`F fWiqjVj&O<`EaUz|z`yY&9^U`q{ZHI$>i+)&Jldyv literal 0 HcmV?d00001 diff --git a/.gitignore b/.gitignore index 8d4a6c0..d18070c 100644 --- a/.gitignore +++ b/.gitignore @@ -1,2 +1,4 @@ bin -obj \ No newline at end of file +obj +.vs +*.png \ No newline at end of file diff --git a/LiloStitcher.cs b/LiloStitcher.cs index ef4eb65..7ae74cc 100644 --- a/LiloStitcher.cs +++ b/LiloStitcher.cs @@ -1,5 +1,8 @@ using Microsoft.Extensions.Caching.Memory; -using SkiaSharp; +using SixLabors.ImageSharp; +using SixLabors.ImageSharp.PixelFormats; +using SixLabors.ImageSharp.Processing; +using SixLabors.ImageSharp.Formats.Png; namespace LiloStitcher; @@ -10,136 +13,140 @@ public record GenerateRequest( double OutputScale ); -public record struct PlateCoordinate( int Row, int Col ) +public readonly record struct PlateCoordinate(int Row, int Col) { - public static PlateCoordinate Parse( string token ) + 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() ); + 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 ); - } + foreach (var c in rowPart) + row = row * 26 + (c - 'A' + 1); - int.TryParse( colPart, out int col ); - - return new PlateCoordinate( row, col ); + int.TryParse(colPart, out int col); + return new PlateCoordinate(row, col); } } -public class TileCache( IMemoryCache cache ) +public class TileCache(IMemoryCache cache) { - private const long TileBytes = 720L * 720 * 4; + private const long TileBytes = 720L * 720 * 3; - public SKBitmap? Get( string key ) => cache.TryGetValue( key, out SKBitmap? bmp ) ? bmp : null; + public Image? Get(string key) => cache.TryGetValue(key, out Image? img) ? img : null; - public void Set( string key, SKBitmap bmp ) => - cache.Set( key, bmp, new MemoryCacheEntryOptions + public void Set(string key, Image img) => + cache.Set(key, img, new MemoryCacheEntryOptions { Size = TileBytes, - SlidingExpiration = TimeSpan.FromMinutes( 20 ) - } ); + SlidingExpiration = TimeSpan.FromMinutes(20) + }); } -public class TileLoader( TileCache cache, string assetDir ) +public class TileLoader(TileCache cache, string assetDir) { - public async Task LoadAsync( string name, CancellationToken ct ) + public async Task> LoadAsync(string name, CancellationToken ct) { - if( cache.Get( name ) is { } hit ) return hit; + 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; + 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 ) +public class LiloStitcher(TileLoader loader) { private const int TileSize = 720; - public async Task CreateImageAsync( GenerateRequest req, CancellationToken ct ) + public async Task CreateImageAsync(GenerateRequest req, CancellationToken ct) { - var parts = req.CanvasRect.ToUpperInvariant().Split( ':', StringSplitOptions.TrimEntries | StringSplitOptions.RemoveEmptyEntries ); + var parts = req.CanvasRect.ToUpperInvariant() + .Split(':', StringSplitOptions.TrimEntries | StringSplitOptions.RemoveEmptyEntries); + var part1 = PlateCoordinate.Parse(parts[0]); + var part2 = PlateCoordinate.Parse(parts[1]); - 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 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}" ) ) + 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; + var bitmaps = await Task.WhenAll(names.Select(n => loader.LoadAsync(n, ct))).ConfigureAwait(false); + var stitched = new Image(cols * TileSize, rows * TileSize); 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 r = 0; r < rows; r++) { - for( int i = 0; i < len; i++ ) + for (int c = 0; c < cols; c++, idx++) { - double v = arr[i]; - double upper = inclusiveUpper ? 1.0 : 1.0 - double.Epsilon; + stitched.Mutate(ctx => ctx.DrawImage(bitmaps[idx], new Point(c * TileSize, r * TileSize), 1f)); } } - static string RowName( int row ) + Validate(req.CropOffset, 2, nameof(req.CropOffset), inclusiveUpper: true); + Validate(req.CropSize, 2, nameof(req.CropSize), inclusiveUpper: true); + + int offsetX = (int)(req.CropOffset[0] * stitched.Width); + int offsetY = (int)(req.CropOffset[1] * stitched.Height); + int cropWidth = (int)(req.CropSize[0] * stitched.Width); + int cropHeight = (int)(req.CropSize[1] * stitched.Height); + + if (offsetX + cropWidth > stitched.Width) cropWidth = stitched.Width - offsetX; + if (offsetY + cropHeight > stitched.Height) cropHeight = stitched.Height - offsetY; + + var cropRect = new Rectangle(offsetX, offsetY, cropWidth, cropHeight); + + using var cropped = stitched.Clone(ctx => ctx.Crop(cropRect)); + + double scale = Math.Clamp(req.OutputScale, 0.0, 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 + { + }, 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 ) + while (row > 0) { row--; - stringBuilder.Insert( 0, (char)( 'A' + row % 26 ) ); + stringBuilder.Insert(0, (char)('A' + row % 26)); row /= 26; } return stringBuilder.ToString(); diff --git a/Program.cs b/Program.cs index 71da835..3f0c08d 100644 --- a/Program.cs +++ b/Program.cs @@ -1,27 +1,31 @@ - +using System.Diagnostics; using Microsoft.Extensions.Caching.Memory; namespace LiloStitcher; public static class Program { - public static async Task Main( string[] args ) + 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"; + Stopwatch sw = Stopwatch.StartNew(); + var begin = sw.ElapsedMilliseconds; + var opt = new Options + { + CanvasRect = "A1:AE55", + CropOffset = [0.0, 0.0], + CropSize = [1.0, 1.0], + OutputScale = 1.0, + OutputPath = "stitched.png" + }; - string tileFilePath = "../stitch-a-ton/tiles1705"; - string assetDir = Path.GetFullPath( Path.Combine( Directory.GetCurrentDirectory(), tileFilePath ) ); + string tileFilePath = "../tiles1705"; // should later be directed to the read-only `ASSET_PATH_RO` environment variable + 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 ); + 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!, @@ -30,16 +34,18 @@ public static class Program opt.OutputScale ); - Console.WriteLine( "Stitching..." ); - var png = await stitcher.CreateImageAsync( req, CancellationToken.None ); + Console.WriteLine("Stitching..."); + var pngBytes = await stitcher.CreateImageAsync(req, CancellationToken.None); - File.WriteAllBytes( opt.OutputPath!, png ); - Console.WriteLine( $"Done. Wrote {opt.OutputPath} ({png.Length / 1024.0:F1} KB)" ); + File.WriteAllBytes(opt.OutputPath!, pngBytes); + Console.WriteLine($"Done. Wrote {opt.OutputPath} ({pngBytes.Length / 1024.0:F1} KB)"); + + Console.WriteLine(begin - sw.ElapsedMilliseconds); return 0; } - catch( Exception ex ) + catch (Exception ex) { - Console.Error.WriteLine( "ERROR: " + ex.Message ); + Console.Error.WriteLine("ERROR: " + ex.Message); return 1; } } diff --git a/lilo-stitcher-console.csproj b/lilo-stitcher-console.csproj index 09af0f4..eb79597 100644 --- a/lilo-stitcher-console.csproj +++ b/lilo-stitcher-console.csproj @@ -10,8 +10,7 @@ - - +