change using NetVips to reduce memory load

This commit is contained in:
Bahru 2025-07-31 23:34:36 +07:00
parent 661c4b955c
commit 49b6f3810d
5 changed files with 210 additions and 220 deletions

13
.idea/.idea.lilo-stitcher-console/.idea/.gitignore generated vendored Normal file
View file

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

View file

@ -0,0 +1,4 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="Encoding" addBOMForNewFiles="with BOM under Windows, with no BOM otherwise" />
</project>

View file

@ -1,10 +1,7 @@
using Microsoft.Extensions.Caching.Memory; using Microsoft.Extensions.Caching.Memory;
using SixLabors.ImageSharp; using NetVips;
using SixLabors.ImageSharp.PixelFormats;
using SixLabors.ImageSharp.Processing;
using SixLabors.ImageSharp.Formats.Png;
namespace LiloStitcher; namespace lilo_stitcher_console;
public record GenerateRequest( public record GenerateRequest(
string CanvasRect, string CanvasRect,
@ -33,9 +30,9 @@ public class TileCache(IMemoryCache cache)
{ {
private const long TileBytes = 720L * 720 * 3; private const long TileBytes = 720L * 720 * 3;
public Image<Rgb24>? Get(string key) => cache.TryGetValue(key, out Image<Rgb24>? img) ? img : null; public Image? Get(string key) => cache.TryGetValue(key, out Image? img) ? img : null;
public void Set(string key, Image<Rgb24> img) => public void Set(string key, Image img) =>
cache.Set(key, img, new MemoryCacheEntryOptions cache.Set(key, img, new MemoryCacheEntryOptions
{ {
Size = TileBytes, Size = TileBytes,
@ -45,14 +42,14 @@ public class TileCache(IMemoryCache cache)
public class TileLoader(TileCache cache, string assetDir) public class TileLoader(TileCache cache, string assetDir)
{ {
public async Task<Image<Rgb24>> LoadAsync(string name, CancellationToken ct) public async Task<Image> LoadAsync(string name, CancellationToken ct)
{ {
if (cache.Get(name) is { } hit) if (cache.Get(name) is { } hit)
return hit; return hit;
var path = Path.Combine(assetDir, $"{name}.png"); var image = await Task.Run(() =>
await using var fs = File.OpenRead(path); Image.NewFromFile(Path.Combine(assetDir, $"{name}.png"), access: Enums.Access.Sequential), ct);
var image = await Image.LoadAsync<Rgb24>(fs, ct).ConfigureAwait(false);
cache.Set(name, image); cache.Set(name, image);
return image; return image;
} }
@ -60,102 +57,69 @@ public class TileLoader(TileCache cache, string assetDir)
public class LiloStitcher(TileLoader loader) public class LiloStitcher(TileLoader loader)
{ {
private const int TileSize = 720; public async Task<string> CreateImageAsync(GenerateRequest req, CancellationToken ct)
private static readonly GraphicsOptions _copy = new()
{ {
Antialias = false, (int rowMin, int colMin, int rows, int cols) = ParseCanvas(req.CanvasRect);
AlphaCompositionMode = PixelAlphaCompositionMode.Src,
BlendPercentage = 1f
};
public async Task<byte[]> CreateImageAsync(GenerateRequest req, CancellationToken ct) 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<Image>(rows * cols);
for( int row = 0; row < rows; row++ )
{ {
var parts = req.CanvasRect.ToUpperInvariant().Split(':', StringSplitOptions.TrimEntries | for( int col = 0; col < cols; col++ )
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<Rgb24>(cols * TileSize, rows * TileSize);
stitched.Mutate(context =>
{ {
int idx = 0; string id = $"{RowName( rowMin + row )}{colMin + col}";
for (int row = 0; row < rows; row++) var tile = await loader.LoadAsync( id, ct );
{ if( scale < 1 ) tile = tile.Resize( scale );
for (int col = 0; col < cols; col++, idx++) tiles.Add( tile );
{
context.DrawImage(bitmaps[idx], new Point(col * TileSize, row * TileSize), 1f);
} }
} }
});
Validate(req.CropOffset, 2, nameof(req.CropOffset), inclusiveUpper: true); var mosaic = Image.Arrayjoin(tiles.ToArray(), across: cols);
Validate(req.CropSize, 2, nameof(req.CropSize), inclusiveUpper: true);
int offsetX = (int)Math.Truncate(req.CropOffset[0] * stitched.Width); int offsetX = (int)Math.Truncate(req.CropOffset[0] * mosaic.Width);
int offsetY = (int)Math.Truncate(req.CropOffset[1] * stitched.Height); int offsetY = (int)Math.Truncate(req.CropOffset[1] * mosaic.Height);
int restWidth = stitched.Width - offsetX; int restWidth = mosaic.Width - offsetX;
int restHeight = stitched.Height - offsetY; int restHeight = mosaic.Height - offsetY;
int cropWidth = Math.Max(1, (int)Math.Truncate(req.CropSize[0] * restWidth )); 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 cropHeight = Math.Max(1, (int)Math.Truncate(req.CropSize[1] * restHeight));
int cropX = (int)Math.Truncate(offsetX / 2.0 + (restWidth - cropWidth) / 2.0); int cropX = (int)Math.Truncate(offsetX / 2.0 + (restWidth - cropWidth) / 2.0);
int cropY = (int)Math.Truncate(offsetY / 2.0 + (restHeight - cropHeight) / 2.0); int cropY = (int)Math.Truncate(offsetY / 2.0 + (restHeight - cropHeight) / 2.0);
var cropRect = new Rectangle(cropX, cropY, cropWidth, cropHeight); var cropRect = mosaic.Crop(cropX, cropY, cropWidth, cropHeight);
using var cropped = stitched.Clone(context => context.Crop(cropRect));
double scale = Math.Clamp(req.OutputScale, 0.0, 1.0); string tmpPath = Path.Combine(Path.GetTempPath(), $"mosaic-{Guid.NewGuid():N}.png");
if (scale <= 0 || scale > 1) cropRect.WriteToFile(tmpPath);
throw new ArgumentOutOfRangeException(nameof(req.OutputScale), "OutputScale must be > 0 and ≤ 1.0");
Image<Rgb24> output; return tmpPath;
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(); private static (int rowMin, int colMin, int rows, int cols) ParseCanvas(string rect)
await output.SaveAsync(memStream, new PngEncoder
{ {
CompressionLevel = PngCompressionLevel.Level1 var parts = rect.ToUpperInvariant().Split(':', StringSplitOptions.RemoveEmptyEntries);
}, ct).ConfigureAwait(false); var part1 = PlateCoordinate.Parse(parts[0]);
return memStream.ToArray(); 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);
} }
static void Validate(double[] arr, int len, string name, bool inclusiveUpper) private static void Validate(double[] arr, string name)
{ {
if (arr is null || arr.Length < len) if (arr is null || arr.Length < 2)
throw new ArgumentException($"{name} must have length {len}"); throw new ArgumentException($"{name} length");
double upper = inclusiveUpper ? 1.0 : 1.0 - double.Epsilon; if (arr.Any(x => x < 0 || x > 1))
for (int i = 0; i < len; i++) throw new ArgumentOutOfRangeException(name);
{
var v = arr[i];
if (v < 0 || v > upper)
throw new ArgumentOutOfRangeException($"{name}[{i}]= {v} outside [0,{upper}]");
}
} }
static string RowName(int row) static string RowName(int row)

View file

@ -1,20 +1,25 @@
using System.Diagnostics; using lilo_stitcher_console;
using System.Diagnostics;
using Microsoft.Extensions.Caching.Memory; using Microsoft.Extensions.Caching.Memory;
namespace LiloStitcher; namespace LiloStitcher;
public static class Program public static class Program
{ {
public static async Task<int> Main(string[] args) public static async Task<int> Main( string[] args )
{ {
try try
{ {
NetVips.NetVips.Init();
NetVips.NetVips.Concurrency = 3;
Stopwatch sw = Stopwatch.StartNew(); Stopwatch sw = Stopwatch.StartNew();
var begin = sw.ElapsedMilliseconds; var begin = sw.ElapsedMilliseconds;
var opt = new Options var opt = new Options
{ {
CanvasRect = "A1:AE55", CanvasRect = "A1:AE55",
CropOffset = [0.4, 0.4], CropOffset = new[] { 0.4, 0.4 },
CropSize = [0.8, 0.8], CropSize = new[] { 0.8, 0.8 },
OutputScale = 0.5, OutputScale = 0.5,
OutputPath = "stitched.png" OutputPath = "stitched.png"
}; };
@ -22,10 +27,10 @@ public static class Program
string tileFilePath = "../tiles1705"; // should later be directed to the read-only `ASSET_PATH_RO` environment variable 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 assetDir = Path.GetFullPath(Path.Combine(Directory.GetCurrentDirectory(), tileFilePath));
using var memCache = new MemoryCache(new MemoryCacheOptions { SizeLimit = 256L * 1024 * 1024 }); using var memCache = new MemoryCache(new MemoryCacheOptions { SizeLimit = 128L * 1024 * 1024 });
var tileCache = new TileCache(memCache); var tileCache = new TileCache(memCache);
var loader = new TileLoader(tileCache, assetDir); var loader = new TileLoader(tileCache, assetDir);
var stitcher = new LiloStitcher(loader); var stitcher = new lilo_stitcher_console.LiloStitcher(loader);
var req = new GenerateRequest( var req = new GenerateRequest(
opt.CanvasRect!, opt.CanvasRect!,
@ -35,17 +40,19 @@ public static class Program
); );
Console.WriteLine("Stitching..."); Console.WriteLine("Stitching...");
var pngBytes = await stitcher.CreateImageAsync(req, CancellationToken.None); var png = await stitcher.CreateImageAsync(req, CancellationToken.None);
File.Move( png, opt.OutputPath!, overwrite: true );
File.WriteAllBytes(opt.OutputPath!, pngBytes); long bytes = new FileInfo( opt.OutputPath! ).Length;
Console.WriteLine($"Done. Wrote {opt.OutputPath} ({pngBytes.Length / 1024.0:F1} KB)"); Console.WriteLine($"Done. Wrote {opt.OutputPath} ({bytes / 1024.0:F1} KB)");
Console.WriteLine(sw.ElapsedMilliseconds - begin); Console.WriteLine(sw.ElapsedMilliseconds - begin);
return 0; return 0;
} }
catch (Exception ex) catch( Exception ex )
{ {
Console.Error.WriteLine("ERROR: " + ex.Message); Console.Error.WriteLine( "ERROR: " + ex.Message );
return 1; return 1;
} }
} }

View file

@ -2,15 +2,17 @@
<PropertyGroup> <PropertyGroup>
<OutputType>Exe</OutputType> <OutputType>Exe</OutputType>
<TargetFramework>net8.0</TargetFramework> <TargetFramework>net9.0</TargetFramework>
<RootNamespace>lilo_stitcher_console</RootNamespace> <RootNamespace>lilo_stitcher_console</RootNamespace>
<ImplicitUsings>enable</ImplicitUsings> <ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable> <Nullable>enable</Nullable>
<LangVersion>preview</LangVersion>
</PropertyGroup> </PropertyGroup>
<ItemGroup> <ItemGroup>
<PackageReference Include="Microsoft.Extensions.Caching.Memory" Version="9.0.7" /> <PackageReference Include="Microsoft.Extensions.Caching.Memory" Version="9.0.7" />
<PackageReference Include="SixLabors.ImageSharp" Version="3.1.10" /> <PackageReference Include="NetVips" Version="3.1.0" />
<PackageReference Include="NetVips.Native.linux-x64" Version="8.17.1" />
</ItemGroup> </ItemGroup>
</Project> </Project>