2025-07-19 17:44:08 +07:00
|
|
|
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;
|
2025-07-19 17:44:08 +07:00
|
|
|
|
|
|
|
|
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-19 17:44:08 +07:00
|
|
|
{
|
2025-07-21 00:08:32 +07:00
|
|
|
public static PlateCoordinate Parse(string token)
|
2025-07-19 17:44:08 +07:00
|
|
|
{
|
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());
|
2025-07-19 17:44:08 +07:00
|
|
|
|
|
|
|
|
int row = 0;
|
2025-07-31 12:57:14 +07:00
|
|
|
foreach (var character in rowPart)
|
|
|
|
|
row = row * 26 + (character - 'A' + 1);
|
2025-07-19 17:44:08 +07:00
|
|
|
|
2025-07-21 00:08:32 +07:00
|
|
|
int.TryParse(colPart, out int col);
|
|
|
|
|
return new PlateCoordinate(row, col);
|
2025-07-19 17:44:08 +07:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2025-07-21 00:08:32 +07:00
|
|
|
public class TileCache(IMemoryCache cache)
|
2025-07-19 17:44:08 +07:00
|
|
|
{
|
2025-07-21 00:08:32 +07:00
|
|
|
private const long TileBytes = 720L * 720 * 3;
|
2025-07-19 17:44:08 +07:00
|
|
|
|
2025-07-31 12:57:14 +07:00
|
|
|
public Image<Rgb24>? Get(string key) => cache.TryGetValue(key, out Image<Rgb24>? img) ? img : null;
|
2025-07-19 17:44:08 +07:00
|
|
|
|
2025-07-31 12:57:14 +07:00
|
|
|
public void Set(string key, Image<Rgb24> img) =>
|
2025-07-21 00:08:32 +07:00
|
|
|
cache.Set(key, img, new MemoryCacheEntryOptions
|
2025-07-19 17:44:08 +07:00
|
|
|
{
|
|
|
|
|
Size = TileBytes,
|
2025-07-21 00:08:32 +07:00
|
|
|
SlidingExpiration = TimeSpan.FromMinutes(20)
|
|
|
|
|
});
|
2025-07-19 17:44:08 +07:00
|
|
|
}
|
|
|
|
|
|
2025-07-21 00:08:32 +07:00
|
|
|
public class TileLoader(TileCache cache, string assetDir)
|
2025-07-19 17:44:08 +07:00
|
|
|
{
|
2025-07-31 12:57:14 +07:00
|
|
|
public async Task<Image<Rgb24>> LoadAsync(string name, CancellationToken ct)
|
2025-07-19 17:44:08 +07:00
|
|
|
{
|
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);
|
2025-07-31 12:57:14 +07:00
|
|
|
var image = await Image.LoadAsync<Rgb24>(fs, ct).ConfigureAwait(false);
|
2025-07-21 00:08:32 +07:00
|
|
|
cache.Set(name, image);
|
|
|
|
|
return image;
|
2025-07-19 17:44:08 +07:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2025-07-21 00:08:32 +07:00
|
|
|
public class LiloStitcher(TileLoader loader)
|
2025-07-19 17:44:08 +07:00
|
|
|
{
|
|
|
|
|
private const int TileSize = 720;
|
|
|
|
|
|
2025-07-31 12:57:14 +07:00
|
|
|
private static readonly GraphicsOptions _copy = new()
|
|
|
|
|
{
|
|
|
|
|
Antialias = false,
|
|
|
|
|
AlphaCompositionMode = PixelAlphaCompositionMode.Src,
|
|
|
|
|
BlendPercentage = 1f
|
|
|
|
|
};
|
|
|
|
|
|
2025-07-21 00:08:32 +07:00
|
|
|
public async Task<byte[]> CreateImageAsync(GenerateRequest req, CancellationToken ct)
|
2025-07-19 17:44:08 +07:00
|
|
|
{
|
2025-07-31 12:57:14 +07:00
|
|
|
var parts = req.CanvasRect.ToUpperInvariant().Split(':', StringSplitOptions.TrimEntries |
|
|
|
|
|
StringSplitOptions.RemoveEmptyEntries);
|
2025-07-21 00:08:32 +07:00
|
|
|
var part1 = PlateCoordinate.Parse(parts[0]);
|
|
|
|
|
var part2 = PlateCoordinate.Parse(parts[1]);
|
2025-07-19 17:44:08 +07:00
|
|
|
|
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);
|
2025-07-19 17:44:08 +07:00
|
|
|
|
|
|
|
|
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}"))
|
2025-07-19 17:44:08 +07:00
|
|
|
.ToArray();
|
|
|
|
|
|
2025-07-21 00:08:32 +07:00
|
|
|
var bitmaps = await Task.WhenAll(names.Select(n => loader.LoadAsync(n, ct))).ConfigureAwait(false);
|
2025-07-31 12:57:14 +07:00
|
|
|
var stitched = new Image<Rgb24>(cols * TileSize, rows * TileSize);
|
2025-07-19 17:44:08 +07:00
|
|
|
|
2025-07-31 12:57:14 +07:00
|
|
|
stitched.Mutate(context =>
|
2025-07-21 00:08:32 +07:00
|
|
|
{
|
2025-07-31 12:57:14 +07:00
|
|
|
int idx = 0;
|
|
|
|
|
for (int row = 0; row < rows; row++)
|
2025-07-21 00:08:32 +07:00
|
|
|
{
|
2025-07-31 12:57:14 +07:00
|
|
|
for (int col = 0; col < cols; col++, idx++)
|
|
|
|
|
{
|
|
|
|
|
context.DrawImage(bitmaps[idx], new Point(col * TileSize, row * TileSize), 1f);
|
|
|
|
|
}
|
2025-07-21 00:08:32 +07:00
|
|
|
}
|
2025-07-31 12:57:14 +07:00
|
|
|
});
|
2025-07-19 17:44:08 +07:00
|
|
|
|
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-19 17:44:08 +07:00
|
|
|
|
2025-07-31 12:57:14 +07:00
|
|
|
int offsetX = (int)Math.Truncate(req.CropOffset[0] * stitched.Width);
|
|
|
|
|
int offsetY = (int)Math.Truncate(req.CropOffset[1] * stitched.Height);
|
2025-07-19 17:44:08 +07:00
|
|
|
|
2025-07-31 12:57:14 +07:00
|
|
|
int restWidth = stitched.Width - offsetX;
|
|
|
|
|
int restHeight = stitched.Height - offsetY;
|
2025-07-19 17:44:08 +07:00
|
|
|
|
2025-07-31 12:57:14 +07:00
|
|
|
int cropWidth = Math.Max(1, (int)Math.Truncate(req.CropSize[0] * restWidth ));
|
|
|
|
|
int cropHeight = Math.Max(1, (int)Math.Truncate(req.CropSize[1] * restHeight));
|
2025-07-19 17:44:08 +07:00
|
|
|
|
2025-07-31 12:57:14 +07:00
|
|
|
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));
|
2025-07-19 17:44:08 +07:00
|
|
|
|
2025-07-21 00:08:32 +07:00
|
|
|
double scale = Math.Clamp(req.OutputScale, 0.0, 1.0);
|
2025-07-31 12:57:14 +07:00
|
|
|
if (scale <= 0 || scale > 1)
|
|
|
|
|
throw new ArgumentOutOfRangeException(nameof(req.OutputScale), "OutputScale must be > 0 and ≤ 1.0");
|
|
|
|
|
|
|
|
|
|
Image<Rgb24> output;
|
2025-07-21 00:08:32 +07:00
|
|
|
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-19 17:44:08 +07:00
|
|
|
|
2025-07-21 00:08:32 +07:00
|
|
|
using var memStream = new MemoryStream();
|
|
|
|
|
await output.SaveAsync(memStream, new PngEncoder
|
|
|
|
|
{
|
2025-07-31 12:57:14 +07:00
|
|
|
CompressionLevel = PngCompressionLevel.Level1
|
2025-07-21 00:08:32 +07:00
|
|
|
}, ct).ConfigureAwait(false);
|
|
|
|
|
return memStream.ToArray();
|
2025-07-31 12:57:14 +07:00
|
|
|
}
|
2025-07-19 17:44:08 +07:00
|
|
|
|
2025-07-31 12:57:14 +07:00
|
|
|
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;
|
|
|
|
|
for (int i = 0; i < len; i++)
|
2025-07-19 17:44:08 +07:00
|
|
|
{
|
2025-07-31 12:57:14 +07:00
|
|
|
var v = arr[i];
|
|
|
|
|
if (v < 0 || v > upper)
|
|
|
|
|
throw new ArgumentOutOfRangeException($"{name}[{i}]= {v} outside [0,{upper}]");
|
2025-07-19 17:44:08 +07:00
|
|
|
}
|
2025-07-31 12:57:14 +07:00
|
|
|
}
|
2025-07-19 17:44:08 +07:00
|
|
|
|
2025-07-31 12:57:14 +07:00
|
|
|
static string RowName(int row)
|
|
|
|
|
{
|
|
|
|
|
var stringBuilder = new System.Text.StringBuilder();
|
|
|
|
|
while (row > 0)
|
2025-07-19 17:44:08 +07:00
|
|
|
{
|
2025-07-31 12:57:14 +07:00
|
|
|
row--;
|
|
|
|
|
stringBuilder.Insert(0, (char)('A' + row % 26));
|
|
|
|
|
row /= 26;
|
2025-07-19 17:44:08 +07:00
|
|
|
}
|
2025-07-31 12:57:14 +07:00
|
|
|
return stringBuilder.ToString();
|
2025-07-19 17:44:08 +07:00
|
|
|
}
|
|
|
|
|
}
|