lilo-stitcher/LiloStitcher.cs

136 lines
4.4 KiB
C#

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<Image> 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<string> 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<Image>(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();
}
}