refactor into using ImageSharp

This commit is contained in:
mbsbahru 2025-07-21 00:08:32 +07:00
parent efaaee3b17
commit db3c833c4c
5 changed files with 125 additions and 111 deletions

BIN
.DS_Store vendored Normal file

Binary file not shown.

2
.gitignore vendored
View file

@ -1,2 +1,4 @@
bin bin
obj obj
.vs
*.png

View file

@ -1,5 +1,8 @@
using Microsoft.Extensions.Caching.Memory; 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; namespace LiloStitcher;
@ -10,136 +13,140 @@ public record GenerateRequest(
double OutputScale 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 rowPart = new string(token.TakeWhile(char.IsLetter).ToArray()).ToUpperInvariant();
var colPart = new string( token.SkipWhile( char.IsLetter ).ToArray() ); var colPart = new string(token.SkipWhile(char.IsLetter).ToArray());
int row = 0; int row = 0;
foreach( var c in rowPart ) foreach (var c in rowPart)
{ row = row * 26 + (c - 'A' + 1);
row = row * 26 + ( c - 'A' + 1 );
}
int.TryParse( colPart, out int col ); int.TryParse(colPart, out int col);
return new PlateCoordinate(row, 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<Rgba32>? Get(string key) => cache.TryGetValue(key, out Image<Rgba32>? img) ? img : null;
public void Set( string key, SKBitmap bmp ) => public void Set(string key, Image<Rgba32> img) =>
cache.Set( key, bmp, new MemoryCacheEntryOptions cache.Set(key, img, new MemoryCacheEntryOptions
{ {
Size = TileBytes, 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<SKBitmap> LoadAsync( string name, CancellationToken ct ) public async Task<Image<Rgba32>> 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 path = Path.Combine(assetDir, $"{name}.png");
await using var fs = File.OpenRead(path);
var bytes = await File.ReadAllBytesAsync( path, ct ).ConfigureAwait( false ); var image = await Image.LoadAsync<Rgba32>(fs, ct).ConfigureAwait(false);
cache.Set(name, image);
using var data = SKData.CreateCopy( bytes ); return image;
var bmp = SKBitmap.Decode( data );
cache.Set( name, bmp );
return bmp;
} }
} }
public class LiloStitcher( TileLoader loader ) public class LiloStitcher(TileLoader loader)
{ {
private const int TileSize = 720; private const int TileSize = 720;
public async Task<byte[]> CreateImageAsync( GenerateRequest req, CancellationToken ct ) public async Task<byte[]> 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] ); int rowMin = Math.Min(part1.Row, part2.Row);
var part2 = PlateCoordinate.Parse( parts[1] ); int rowMax = Math.Max(part1.Row, part2.Row);
int colMin = Math.Min(part1.Col, part2.Col);
int rowMin = Math.Min( part1.Row, part2.Row ); int colMax = Math.Max(part1.Col, part2.Col);
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 rows = rowMax - rowMin + 1;
int cols = colMax - colMin + 1; int cols = colMax - colMin + 1;
var names = Enumerable.Range( rowMin, rows ) var names = Enumerable.Range(rowMin, rows)
.SelectMany( r => Enumerable.Range( colMin, cols ).Select( c => $"{RowName( r )}{c}" ) ) .SelectMany(r => Enumerable.Range(colMin, cols).Select(c => $"{RowName(r)}{c}"))
.ToArray(); .ToArray();
var bitmaps = await Task.WhenAll( names.Select( n => loader.LoadAsync( n, ct ) ) ).ConfigureAwait( false ); var bitmaps = await Task.WhenAll(names.Select(n => loader.LoadAsync(n, ct))).ConfigureAwait(false);
var stitched = new Image<Rgba32>(cols * TileSize, rows * TileSize);
var fullInfo = new SKImageInfo( cols * TileSize, rows * TileSize );
using var surface = SKSurface.Create( fullInfo );
var canvas = surface.Canvas;
int idx = 0; int idx = 0;
for( int row = 0; row < rows; row++ ) for (int r = 0; r < rows; r++)
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 i = 0; i < len; i++ ) for (int c = 0; c < cols; c++, idx++)
{ {
double v = arr[i]; stitched.Mutate(ctx => ctx.DrawImage(bitmaps[idx], new Point(c * TileSize, r * TileSize), 1f));
}
}
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<Rgba32> 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; 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 ) static string RowName(int row)
{ {
var stringBuilder = new System.Text.StringBuilder(); var stringBuilder = new System.Text.StringBuilder();
while( row > 0 ) while (row > 0)
{ {
row--; row--;
stringBuilder.Insert( 0, (char)( 'A' + row % 26 ) ); stringBuilder.Insert(0, (char)('A' + row % 26));
row /= 26; row /= 26;
} }
return stringBuilder.ToString(); return stringBuilder.ToString();

View file

@ -1,27 +1,31 @@
 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
{ {
var opt = new Options(); Stopwatch sw = Stopwatch.StartNew();
opt.CanvasRect = "A1:H12"; var begin = sw.ElapsedMilliseconds;
opt.CropOffset = [0.25, 0.25]; var opt = new Options
opt.CropSize = [0.75, 0.75]; {
opt.OutputScale = 0.5; CanvasRect = "A1:AE55",
opt.OutputPath = "stitched.png"; CropOffset = [0.0, 0.0],
CropSize = [1.0, 1.0],
OutputScale = 1.0,
OutputPath = "stitched.png"
};
string tileFilePath = "../stitch-a-ton/tiles1705"; 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 = 256L * 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 LiloStitcher(loader);
var req = new GenerateRequest( var req = new GenerateRequest(
opt.CanvasRect!, opt.CanvasRect!,
@ -30,16 +34,18 @@ public static class Program
opt.OutputScale opt.OutputScale
); );
Console.WriteLine( "Stitching..." ); Console.WriteLine("Stitching...");
var png = await stitcher.CreateImageAsync( req, CancellationToken.None ); var pngBytes = await stitcher.CreateImageAsync(req, CancellationToken.None);
File.WriteAllBytes( opt.OutputPath!, png ); File.WriteAllBytes(opt.OutputPath!, pngBytes);
Console.WriteLine( $"Done. Wrote {opt.OutputPath} ({png.Length / 1024.0:F1} KB)" ); Console.WriteLine($"Done. Wrote {opt.OutputPath} ({pngBytes.Length / 1024.0:F1} KB)");
Console.WriteLine(begin - sw.ElapsedMilliseconds);
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

@ -10,8 +10,7 @@
<ItemGroup> <ItemGroup>
<PackageReference Include="Microsoft.Extensions.Caching.Memory" Version="9.0.7" /> <PackageReference Include="Microsoft.Extensions.Caching.Memory" Version="9.0.7" />
<PackageReference Include="SkiaSharp" Version="3.119.0" /> <PackageReference Include="SixLabors.ImageSharp" Version="3.1.10" />
<PackageReference Include="SkiaSharp.NativeAssets.Linux" Version="3.119.0" />
</ItemGroup> </ItemGroup>
</Project> </Project>