From 49b6f3810d9ed26de7fce84c6d1fcb361b781413 Mon Sep 17 00:00:00 2001 From: Bahru Date: Thu, 31 Jul 2025 23:34:36 +0700 Subject: [PATCH] change using NetVips to reduce memory load --- .../.idea/.gitignore | 13 + .../.idea/encodings.xml | 4 + LiloStitcher.cs | 308 ++++++++---------- Program.cs | 99 +++--- lilo-stitcher-console.csproj | 6 +- 5 files changed, 210 insertions(+), 220 deletions(-) create mode 100644 .idea/.idea.lilo-stitcher-console/.idea/.gitignore create mode 100644 .idea/.idea.lilo-stitcher-console/.idea/encodings.xml diff --git a/.idea/.idea.lilo-stitcher-console/.idea/.gitignore b/.idea/.idea.lilo-stitcher-console/.idea/.gitignore new file mode 100644 index 0000000..4be9211 --- /dev/null +++ b/.idea/.idea.lilo-stitcher-console/.idea/.gitignore @@ -0,0 +1,13 @@ +# Default ignored files +/shelf/ +/workspace.xml +# Rider ignored files +/modules.xml +/contentModel.xml +/projectSettingsUpdater.xml +/.idea.lilo-stitcher-console.iml +# Editor-based HTTP Client requests +/httpRequests/ +# Datasource local storage ignored files +/dataSources/ +/dataSources.local.xml diff --git a/.idea/.idea.lilo-stitcher-console/.idea/encodings.xml b/.idea/.idea.lilo-stitcher-console/.idea/encodings.xml new file mode 100644 index 0000000..df87cf9 --- /dev/null +++ b/.idea/.idea.lilo-stitcher-console/.idea/encodings.xml @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/LiloStitcher.cs b/LiloStitcher.cs index 7ac174f..a2ed464 100644 --- a/LiloStitcher.cs +++ b/LiloStitcher.cs @@ -1,172 +1,136 @@ -using Microsoft.Extensions.Caching.Memory; -using SixLabors.ImageSharp; -using SixLabors.ImageSharp.PixelFormats; -using SixLabors.ImageSharp.Processing; -using SixLabors.ImageSharp.Formats.Png; - -namespace LiloStitcher; - -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 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) -{ - private const int TileSize = 720; - - private static readonly GraphicsOptions _copy = new() - { - Antialias = false, - AlphaCompositionMode = PixelAlphaCompositionMode.Src, - BlendPercentage = 1f - }; - - 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 stitched = new Image(cols * TileSize, rows * TileSize); - - stitched.Mutate(context => - { - int idx = 0; - for (int row = 0; row < rows; row++) - { - for (int col = 0; col < cols; col++, idx++) - { - context.DrawImage(bitmaps[idx], new Point(col * TileSize, row * TileSize), 1f); - } - } - }); - - Validate(req.CropOffset, 2, nameof(req.CropOffset), inclusiveUpper: true); - Validate(req.CropSize, 2, nameof(req.CropSize), inclusiveUpper: true); - - int offsetX = (int)Math.Truncate(req.CropOffset[0] * stitched.Width); - int offsetY = (int)Math.Truncate(req.CropOffset[1] * stitched.Height); - - int restWidth = stitched.Width - offsetX; - int restHeight = stitched.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 = new Rectangle(cropX, cropY, cropWidth, cropHeight); - using var cropped = stitched.Clone(context => context.Crop(cropRect)); - - double scale = Math.Clamp(req.OutputScale, 0.0, 1.0); - if (scale <= 0 || scale > 1) - throw new ArgumentOutOfRangeException(nameof(req.OutputScale), "OutputScale must be > 0 and ≤ 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 - { - CompressionLevel = PngCompressionLevel.Level1 - }, 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) - { - row--; - stringBuilder.Insert(0, (char)('A' + row % 26)); - row /= 26; - } - return stringBuilder.ToString(); - } -} \ No newline at end of file +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(); + } +} diff --git a/Program.cs b/Program.cs index 253020f..7019819 100644 --- a/Program.cs +++ b/Program.cs @@ -1,61 +1,68 @@ -using System.Diagnostics; +using lilo_stitcher_console; +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 { - try - { - Stopwatch sw = Stopwatch.StartNew(); - var begin = sw.ElapsedMilliseconds; - var opt = new Options - { - CanvasRect = "A1:AE55", - CropOffset = [0.4, 0.4], - CropSize = [0.8, 0.8], - OutputScale = 0.5, - OutputPath = "stitched.png" - }; + NetVips.NetVips.Init(); + NetVips.NetVips.Concurrency = 3; + + Stopwatch sw = Stopwatch.StartNew(); + var begin = sw.ElapsedMilliseconds; + var opt = new Options + { + CanvasRect = "A1:AE55", + CropOffset = new[] { 0.4, 0.4 }, + CropSize = new[] { 0.8, 0.8 }, + OutputScale = 0.5, + OutputPath = "stitched.png" + }; - 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)); + 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 = 128L * 1024 * 1024 }); + var tileCache = new TileCache(memCache); + var loader = new TileLoader(tileCache, assetDir); + var stitcher = new lilo_stitcher_console.LiloStitcher(loader); - var req = new GenerateRequest( - opt.CanvasRect!, - opt.CropOffset!, - opt.CropSize!, - opt.OutputScale - ); + var req = new GenerateRequest( + opt.CanvasRect!, + opt.CropOffset!, + opt.CropSize!, + opt.OutputScale + ); - Console.WriteLine("Stitching..."); - var pngBytes = await stitcher.CreateImageAsync(req, CancellationToken.None); + Console.WriteLine("Stitching..."); + var png = await stitcher.CreateImageAsync(req, CancellationToken.None); + File.Move( png, opt.OutputPath!, overwrite: true ); + + long bytes = new FileInfo( opt.OutputPath! ).Length; + Console.WriteLine($"Done. Wrote {opt.OutputPath} ({bytes / 1024.0:F1} KB)"); - File.WriteAllBytes(opt.OutputPath!, pngBytes); - Console.WriteLine($"Done. Wrote {opt.OutputPath} ({pngBytes.Length / 1024.0:F1} KB)"); - - Console.WriteLine(sw.ElapsedMilliseconds - begin); - return 0; - } - catch (Exception ex) - { - Console.Error.WriteLine("ERROR: " + ex.Message); - return 1; - } + Console.WriteLine(sw.ElapsedMilliseconds - begin); + + return 0; } - - private struct Options + catch( Exception ex ) { - 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; } + 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 index 05566b9..cd56a0f 100644 --- a/lilo-stitcher-console.csproj +++ b/lilo-stitcher-console.csproj @@ -2,15 +2,17 @@ Exe - net8.0 + net9.0 lilo_stitcher_console enable enable + preview - + +