lilo-stitcher/LiloStitcher.cs

155 lines
5.2 KiB
C#
Raw Permalink Normal View History

using Microsoft.Extensions.Caching.Memory;
2025-07-21 00:08:32 +07:00
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
);
2025-07-21 00:08:32 +07:00
public readonly record struct PlateCoordinate(int Row, int Col)
{
2025-07-21 00:08:32 +07:00
public static PlateCoordinate Parse(string token)
{
2025-07-21 00:08:32 +07:00
var rowPart = new string(token.TakeWhile(char.IsLetter).ToArray()).ToUpperInvariant();
var colPart = new string(token.SkipWhile(char.IsLetter).ToArray());
int row = 0;
2025-07-21 00:08:32 +07:00
foreach (var c in rowPart)
row = row * 26 + (c - 'A' + 1);
2025-07-21 00:08:32 +07:00
int.TryParse(colPart, out int col);
return new PlateCoordinate(row, col);
}
}
2025-07-21 00:08:32 +07:00
public class TileCache(IMemoryCache cache)
{
2025-07-21 00:08:32 +07:00
private const long TileBytes = 720L * 720 * 3;
2025-07-21 00:08:32 +07:00
public Image<Rgba32>? Get(string key) => cache.TryGetValue(key, out Image<Rgba32>? img) ? img : null;
2025-07-21 00:08:32 +07:00
public void Set(string key, Image<Rgba32> img) =>
cache.Set(key, img, new MemoryCacheEntryOptions
{
Size = TileBytes,
2025-07-21 00:08:32 +07:00
SlidingExpiration = TimeSpan.FromMinutes(20)
});
}
2025-07-21 00:08:32 +07:00
public class TileLoader(TileCache cache, string assetDir)
{
2025-07-21 00:08:32 +07:00
public async Task<Image<Rgba32>> LoadAsync(string name, CancellationToken ct)
{
2025-07-21 00:08:32 +07:00
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<Rgba32>(fs, ct).ConfigureAwait(false);
cache.Set(name, image);
return image;
}
}
2025-07-21 00:08:32 +07:00
public class LiloStitcher(TileLoader loader)
{
private const int TileSize = 720;
2025-07-21 00:08:32 +07:00
public async Task<byte[]> CreateImageAsync(GenerateRequest req, CancellationToken ct)
{
2025-07-21 00:08:32 +07:00
var parts = req.CanvasRect.ToUpperInvariant()
.Split(':', StringSplitOptions.TrimEntries | StringSplitOptions.RemoveEmptyEntries);
var part1 = PlateCoordinate.Parse(parts[0]);
var part2 = PlateCoordinate.Parse(parts[1]);
2025-07-21 00:08:32 +07:00
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;
2025-07-21 00:08:32 +07:00
var names = Enumerable.Range(rowMin, rows)
.SelectMany(r => Enumerable.Range(colMin, cols).Select(c => $"{RowName(r)}{c}"))
.ToArray();
2025-07-21 00:08:32 +07:00
var bitmaps = await Task.WhenAll(names.Select(n => loader.LoadAsync(n, ct))).ConfigureAwait(false);
var stitched = new Image<Rgba32>(cols * TileSize, rows * TileSize);
int idx = 0;
2025-07-21 00:08:32 +07:00
for (int r = 0; r < rows; r++)
{
for (int c = 0; c < cols; c++, idx++)
{
stitched.Mutate(ctx => ctx.DrawImage(bitmaps[idx], new Point(c * TileSize, r * TileSize), 1f));
}
}
2025-07-21 00:08:32 +07:00
Validate(req.CropOffset, 2, nameof(req.CropOffset), inclusiveUpper: true);
Validate(req.CropSize, 2, nameof(req.CropSize), inclusiveUpper: true);
2025-07-21 00:08:32 +07:00
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);
2025-07-21 00:08:32 +07:00
if (offsetX + cropWidth > stitched.Width) cropWidth = stitched.Width - offsetX;
if (offsetY + cropHeight > stitched.Height) cropHeight = stitched.Height - offsetY;
2025-07-21 00:08:32 +07:00
var cropRect = new Rectangle(offsetX, offsetY, cropWidth, cropHeight);
2025-07-21 00:08:32 +07:00
using var cropped = stitched.Clone(ctx => ctx.Crop(cropRect));
2025-07-21 00:08:32 +07:00
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();
}
2025-07-21 00:08:32 +07:00
using var memStream = new MemoryStream();
await output.SaveAsync(memStream, new PngEncoder
{
}, ct).ConfigureAwait(false);
return memStream.ToArray();
2025-07-21 00:08:32 +07:00
static void Validate(double[] arr, int len, string name, bool inclusiveUpper)
{
2025-07-21 00:08:32 +07:00
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++)
{
2025-07-21 00:08:32 +07:00
var v = arr[i];
if (v < 0 || v > upper)
throw new ArgumentOutOfRangeException($"{name}[{i}]= {v} outside [0,{upper}]");
}
}
2025-07-21 00:08:32 +07:00
static string RowName(int row)
{
var stringBuilder = new System.Text.StringBuilder();
2025-07-21 00:08:32 +07:00
while (row > 0)
{
row--;
2025-07-21 00:08:32 +07:00
stringBuilder.Insert(0, (char)('A' + row % 26));
row /= 26;
}
return stringBuilder.ToString();
}
}
}