change using NetVips to reduce memory load
This commit is contained in:
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
13
.idea/.idea.lilo-stitcher-console/.idea/.gitignore
generated
vendored
Normal 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
|
||||
4
.idea/.idea.lilo-stitcher-console/.idea/encodings.xml
generated
Normal file
4
.idea/.idea.lilo-stitcher-console/.idea/encodings.xml
generated
Normal 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>
|
||||
208
LiloStitcher.cs
208
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;
|
||||
using NetVips;
|
||||
|
||||
namespace LiloStitcher;
|
||||
namespace lilo_stitcher_console;
|
||||
|
||||
public record GenerateRequest(
|
||||
string CanvasRect,
|
||||
double[] CropOffset,
|
||||
double[] CropSize,
|
||||
double OutputScale
|
||||
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());
|
||||
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 row = 0;
|
||||
foreach (var character in rowPart)
|
||||
row = row * 26 + (character - '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)
|
||||
{
|
||||
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) =>
|
||||
cache.Set(key, img, new MemoryCacheEntryOptions
|
||||
{
|
||||
Size = TileBytes,
|
||||
SlidingExpiration = TimeSpan.FromMinutes(20)
|
||||
});
|
||||
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<Image<Rgb24>> LoadAsync(string name, CancellationToken ct)
|
||||
{
|
||||
if (cache.Get(name) is { } hit)
|
||||
return hit;
|
||||
public async Task<Image> 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<Rgb24>(fs, ct).ConfigureAwait(false);
|
||||
cache.Set(name, image);
|
||||
return image;
|
||||
}
|
||||
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)
|
||||
{
|
||||
private const int TileSize = 720;
|
||||
|
||||
private static readonly GraphicsOptions _copy = new()
|
||||
public async Task<string> CreateImageAsync(GenerateRequest req, CancellationToken ct)
|
||||
{
|
||||
Antialias = false,
|
||||
AlphaCompositionMode = PixelAlphaCompositionMode.Src,
|
||||
BlendPercentage = 1f
|
||||
};
|
||||
(int rowMin, int colMin, int rows, int cols) = ParseCanvas(req.CanvasRect);
|
||||
|
||||
public async Task<byte[]> 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]);
|
||||
Validate(req.CropOffset, nameof(req.CropOffset));
|
||||
Validate(req.CropSize, nameof(req.CropSize));
|
||||
|
||||
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);
|
||||
double scale = req.OutputScale;
|
||||
if (scale <= 0 || scale > 1)
|
||||
throw new ArgumentOutOfRangeException(nameof(req.OutputScale));
|
||||
|
||||
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 =>
|
||||
var tiles = new List<Image>(rows * cols);
|
||||
for( int row = 0; row < rows; row++ )
|
||||
{
|
||||
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);
|
||||
}
|
||||
}
|
||||
});
|
||||
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 );
|
||||
}
|
||||
}
|
||||
|
||||
Validate(req.CropOffset, 2, nameof(req.CropOffset), inclusiveUpper: true);
|
||||
Validate(req.CropSize, 2, nameof(req.CropSize), inclusiveUpper: true);
|
||||
var mosaic = Image.Arrayjoin(tiles.ToArray(), across: cols);
|
||||
|
||||
int offsetX = (int)Math.Truncate(req.CropOffset[0] * stitched.Width);
|
||||
int offsetY = (int)Math.Truncate(req.CropOffset[1] * stitched.Height);
|
||||
int offsetX = (int)Math.Truncate(req.CropOffset[0] * mosaic.Width);
|
||||
int offsetY = (int)Math.Truncate(req.CropOffset[1] * mosaic.Height);
|
||||
|
||||
int restWidth = stitched.Width - offsetX;
|
||||
int restHeight = stitched.Height - offsetY;
|
||||
int restWidth = mosaic.Width - offsetX;
|
||||
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 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));
|
||||
var cropRect = mosaic.Crop(cropX, cropY, cropWidth, cropHeight);
|
||||
|
||||
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");
|
||||
string tmpPath = Path.Combine(Path.GetTempPath(), $"mosaic-{Guid.NewGuid():N}.png");
|
||||
cropRect.WriteToFile(tmpPath);
|
||||
|
||||
Image<Rgb24> 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();
|
||||
return tmpPath;
|
||||
}
|
||||
|
||||
static void Validate(double[] arr, int len, string name, bool inclusiveUpper)
|
||||
private static (int rowMin, int colMin, int rows, int cols) ParseCanvas(string rect)
|
||||
{
|
||||
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}]");
|
||||
}
|
||||
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();
|
||||
var stringBuilder = new System.Text.StringBuilder();
|
||||
while (row > 0)
|
||||
{
|
||||
row--;
|
||||
stringBuilder.Insert(0, (char)('A' + row % 26));
|
||||
row /= 26;
|
||||
}
|
||||
return stringBuilder.ToString();
|
||||
}
|
||||
}
|
||||
97
Program.cs
97
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<int> Main(string[] args)
|
||||
public static async Task<int> 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;
|
||||
|
||||
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));
|
||||
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"
|
||||
};
|
||||
|
||||
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);
|
||||
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));
|
||||
|
||||
var req = new GenerateRequest(
|
||||
opt.CanvasRect!,
|
||||
opt.CropOffset!,
|
||||
opt.CropSize!,
|
||||
opt.OutputScale
|
||||
);
|
||||
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);
|
||||
|
||||
Console.WriteLine("Stitching...");
|
||||
var pngBytes = await stitcher.CreateImageAsync(req, CancellationToken.None);
|
||||
var req = new GenerateRequest(
|
||||
opt.CanvasRect!,
|
||||
opt.CropOffset!,
|
||||
opt.CropSize!,
|
||||
opt.OutputScale
|
||||
);
|
||||
|
||||
File.WriteAllBytes(opt.OutputPath!, pngBytes);
|
||||
Console.WriteLine($"Done. Wrote {opt.OutputPath} ({pngBytes.Length / 1024.0:F1} KB)");
|
||||
Console.WriteLine("Stitching...");
|
||||
var png = await stitcher.CreateImageAsync(req, CancellationToken.None);
|
||||
File.Move( png, opt.OutputPath!, overwrite: true );
|
||||
|
||||
Console.WriteLine(sw.ElapsedMilliseconds - begin);
|
||||
return 0;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Console.Error.WriteLine("ERROR: " + ex.Message);
|
||||
return 1;
|
||||
}
|
||||
long bytes = new FileInfo( opt.OutputPath! ).Length;
|
||||
Console.WriteLine($"Done. Wrote {opt.OutputPath} ({bytes / 1024.0:F1} KB)");
|
||||
|
||||
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; }
|
||||
}
|
||||
}
|
||||
|
|
@ -2,15 +2,17 @@
|
|||
|
||||
<PropertyGroup>
|
||||
<OutputType>Exe</OutputType>
|
||||
<TargetFramework>net8.0</TargetFramework>
|
||||
<TargetFramework>net9.0</TargetFramework>
|
||||
<RootNamespace>lilo_stitcher_console</RootNamespace>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
<LangVersion>preview</LangVersion>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<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>
|
||||
|
||||
</Project>
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue