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
-
+
+