feat : algorithm improvements
- use FastStitchProcessor when the canvas is either large, tall or wide (original algorithm) - introduce StreamingProcessor algorithm for better robust performance - add logger
This commit is contained in:
parent
8cec6e92c5
commit
4ed4eea462
7 changed files with 210 additions and 46 deletions
|
|
@ -1,49 +1,86 @@
|
||||||
using StitcherApi.Models;
|
using StitcherApi.Models;
|
||||||
|
using StitcherApi.Services.Fast;
|
||||||
|
using StitcherApi.Services.Streaming;
|
||||||
using StitcherApi.Services.Utilities;
|
using StitcherApi.Services.Utilities;
|
||||||
|
|
||||||
namespace StitcherApi.Services;
|
namespace StitcherApi.Services;
|
||||||
|
|
||||||
public class ImageService : IImageService
|
public class ImageService : IImageService
|
||||||
{
|
{
|
||||||
private readonly ImageProcessor _processor;
|
private readonly ILogger<ImageService> _logger;
|
||||||
private const int TILE_SIZE = 720;
|
private readonly FastProcessor _fastProcessor;
|
||||||
|
private readonly StreamingProcessor _streamingProcessor;
|
||||||
|
|
||||||
public ImageService(IConfiguration configuration)
|
private const double ASPECT_RATIO_THRESHOLD_TALL = 0.25;
|
||||||
|
private const double ASPECT_RATIO_THRESHOLD_WIDE = 8.0;
|
||||||
|
private const int TILE_COUNT_THRESHOLD = 30;
|
||||||
|
|
||||||
|
public ImageService(IConfiguration config, ILoggerFactory loggerFactory)
|
||||||
{
|
{
|
||||||
|
_logger = loggerFactory.CreateLogger<ImageService>();
|
||||||
string assetPath =
|
string assetPath =
|
||||||
configuration["AssetPath"]
|
config["AssetPath"] ?? throw new InvalidOperationException("AssetPath not configured.");
|
||||||
?? throw new InvalidOperationException("AssetPath is not configured.");
|
|
||||||
_processor = new ImageProcessor(assetPath);
|
_fastProcessor = new FastProcessor(assetPath, loggerFactory.CreateLogger<FastProcessor>());
|
||||||
|
_streamingProcessor = new StreamingProcessor(
|
||||||
|
assetPath,
|
||||||
|
loggerFactory.CreateLogger<StreamingProcessor>()
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<byte[]> GenerateImageAsync(GenerateImageRequest request)
|
public async Task<byte[]> GenerateImageAsync(GenerateImageRequest request)
|
||||||
{
|
{
|
||||||
// 1. Delegate parsing to the CoordinateParser
|
|
||||||
(int minRow, int minCol, int maxRow, int maxCol) = CoordinateParser.ParseCanvasRect(
|
(int minRow, int minCol, int maxRow, int maxCol) = CoordinateParser.ParseCanvasRect(
|
||||||
request.CanvasRect
|
request.CanvasRect
|
||||||
);
|
);
|
||||||
|
|
||||||
// 2. Perform high-level calculations
|
int tileGridWidth = maxCol - minCol + 1;
|
||||||
|
int tileGridHeight = maxRow - minRow + 1;
|
||||||
|
int totalTiles = tileGridWidth * tileGridHeight;
|
||||||
|
|
||||||
|
StitchRequest stitchRequest = CreateStitchRequest(request, minRow, minCol, maxRow, maxCol);
|
||||||
|
|
||||||
|
double aspectRatio = (tileGridHeight > 0) ? (double)tileGridWidth / tileGridHeight : 0;
|
||||||
|
|
||||||
|
if (
|
||||||
|
totalTiles > TILE_COUNT_THRESHOLD
|
||||||
|
|| aspectRatio > ASPECT_RATIO_THRESHOLD_WIDE
|
||||||
|
|| (aspectRatio > 0 && aspectRatio < ASPECT_RATIO_THRESHOLD_TALL)
|
||||||
|
)
|
||||||
|
{
|
||||||
|
_logger.LogInformation("Large, Tall, or Wide canvas detected. Using fast processor.");
|
||||||
|
return await _fastProcessor.ProcessAsync(stitchRequest);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
_logger.LogInformation(
|
||||||
|
"Small, block-shaped canvas detected. Using robust streaming processor."
|
||||||
|
);
|
||||||
|
return await _streamingProcessor.ProcessAsync(stitchRequest);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private StitchRequest CreateStitchRequest(
|
||||||
|
GenerateImageRequest request,
|
||||||
|
int minRow,
|
||||||
|
int minCol,
|
||||||
|
int maxRow,
|
||||||
|
int maxCol
|
||||||
|
)
|
||||||
|
{
|
||||||
|
const int TILE_SIZE = 720;
|
||||||
int stitchedCanvasWidth = (maxCol - minCol + 1) * TILE_SIZE;
|
int stitchedCanvasWidth = (maxCol - minCol + 1) * TILE_SIZE;
|
||||||
int stitchedCanvasHeight = (maxRow - minRow + 1) * TILE_SIZE;
|
int stitchedCanvasHeight = (maxRow - minRow + 1) * TILE_SIZE;
|
||||||
|
|
||||||
int cropX = (int)(request.CropOffset[0] * stitchedCanvasWidth);
|
int cropX = (int)(request.CropOffset[0] * stitchedCanvasWidth);
|
||||||
int cropY = (int)(request.CropOffset[1] * stitchedCanvasHeight);
|
int cropY = (int)(request.CropOffset[1] * stitchedCanvasHeight);
|
||||||
int cropW = (int)(request.CropSize[0] * stitchedCanvasWidth);
|
int cropW = (int)(request.CropSize[0] * stitchedCanvasWidth);
|
||||||
int cropH = (int)(request.CropSize[1] * stitchedCanvasHeight);
|
int cropH = (int)(request.CropSize[1] * stitchedCanvasHeight);
|
||||||
|
int startTileCol = minCol + cropX / TILE_SIZE;
|
||||||
|
int endTileCol = minCol + (cropX + cropW - 1) / TILE_SIZE;
|
||||||
|
int startTileRow = minRow + cropY / TILE_SIZE;
|
||||||
|
int endTileRow = minRow + (cropY + cropH - 1) / TILE_SIZE;
|
||||||
|
|
||||||
if (cropW <= 0 || cropH <= 0)
|
return new StitchRequest(
|
||||||
{
|
|
||||||
throw new ArgumentException("Calculated crop dimensions are invalid.");
|
|
||||||
}
|
|
||||||
|
|
||||||
int startTileCol = minCol + (cropX / TILE_SIZE);
|
|
||||||
int endTileCol = minCol + ((cropX + cropW - 1) / TILE_SIZE);
|
|
||||||
int startTileRow = minRow + (cropY / TILE_SIZE);
|
|
||||||
int endTileRow = minRow + ((cropY + cropH - 1) / TILE_SIZE);
|
|
||||||
|
|
||||||
// 3. Create a parameter object for the processor
|
|
||||||
StitchRequest stitchRequest = new StitchRequest(
|
|
||||||
minRow,
|
minRow,
|
||||||
minCol,
|
minCol,
|
||||||
startTileRow,
|
startTileRow,
|
||||||
|
|
@ -56,8 +93,5 @@ public class ImageService : IImageService
|
||||||
cropH,
|
cropH,
|
||||||
request.OutputScale
|
request.OutputScale
|
||||||
);
|
);
|
||||||
|
|
||||||
// 4. Delegate image processing work
|
|
||||||
return await _processor.StitchAndCropAsync(stitchRequest);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,20 +1,23 @@
|
||||||
using SixLabors.ImageSharp;
|
using SixLabors.ImageSharp;
|
||||||
using SixLabors.ImageSharp.PixelFormats;
|
using SixLabors.ImageSharp.PixelFormats;
|
||||||
using SixLabors.ImageSharp.Processing;
|
using SixLabors.ImageSharp.Processing;
|
||||||
|
using StitcherApi.Services.Utilities;
|
||||||
|
|
||||||
namespace StitcherApi.Services.Utilities;
|
namespace StitcherApi.Services.Fast;
|
||||||
|
|
||||||
internal class ImageProcessor
|
public class FastProcessor
|
||||||
{
|
{
|
||||||
private readonly string _assetPath;
|
private readonly string _assetPath;
|
||||||
|
private readonly ILogger<FastProcessor> _logger;
|
||||||
private const int TILE_SIZE = 720;
|
private const int TILE_SIZE = 720;
|
||||||
|
|
||||||
public ImageProcessor(string assetPath)
|
public FastProcessor(string assetPath, ILogger<FastProcessor> logger)
|
||||||
{
|
{
|
||||||
_assetPath = assetPath;
|
_assetPath = assetPath;
|
||||||
|
_logger = logger;
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<byte[]> StitchAndCropAsync(StitchRequest stitchRequest)
|
public async Task<byte[]> ProcessAsync(StitchRequest stitchRequest)
|
||||||
{
|
{
|
||||||
using Image<Rgba32> finalImage = new(stitchRequest.CropW, stitchRequest.CropH);
|
using Image<Rgba32> finalImage = new(stitchRequest.CropW, stitchRequest.CropH);
|
||||||
|
|
||||||
|
|
@ -30,7 +33,7 @@ internal class ImageProcessor
|
||||||
throw new FileNotFoundException($"Asset not found: {tileFileName}");
|
throw new FileNotFoundException($"Asset not found: {tileFileName}");
|
||||||
}
|
}
|
||||||
|
|
||||||
using Image tileImage = await Image.LoadAsync(tileFilePath);
|
using Image<Rgba32> tileImage = await Image.LoadAsync<Rgba32>(tileFilePath);
|
||||||
|
|
||||||
int tileOriginX = (c - stitchRequest.MinCol) * TILE_SIZE;
|
int tileOriginX = (c - stitchRequest.MinCol) * TILE_SIZE;
|
||||||
int tileOriginY = (r - stitchRequest.MinRow) * TILE_SIZE;
|
int tileOriginY = (r - stitchRequest.MinRow) * TILE_SIZE;
|
||||||
|
|
@ -78,17 +81,3 @@ internal class ImageProcessor
|
||||||
return memoryStream.ToArray();
|
return memoryStream.ToArray();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
internal record StitchRequest(
|
|
||||||
int MinRow,
|
|
||||||
int MinCol,
|
|
||||||
int StartTileRow,
|
|
||||||
int StartTileCol,
|
|
||||||
int EndTileRow,
|
|
||||||
int EndTileCol,
|
|
||||||
int CropX,
|
|
||||||
int CropY,
|
|
||||||
int CropW,
|
|
||||||
int CropH,
|
|
||||||
float OutputScale
|
|
||||||
);
|
|
||||||
15
Services/Utilities/StitchRequest.cs
Normal file
15
Services/Utilities/StitchRequest.cs
Normal file
|
|
@ -0,0 +1,15 @@
|
||||||
|
namespace StitcherApi.Services.Utilities;
|
||||||
|
|
||||||
|
public record StitchRequest(
|
||||||
|
int MinRow,
|
||||||
|
int MinCol,
|
||||||
|
int StartTileRow,
|
||||||
|
int StartTileCol,
|
||||||
|
int EndTileRow,
|
||||||
|
int EndTileCol,
|
||||||
|
int CropX,
|
||||||
|
int CropY,
|
||||||
|
int CropW,
|
||||||
|
int CropH,
|
||||||
|
float OutputScale
|
||||||
|
);
|
||||||
125
Services/Utilities/StreamingProcessor.cs
Normal file
125
Services/Utilities/StreamingProcessor.cs
Normal file
|
|
@ -0,0 +1,125 @@
|
||||||
|
using System.Collections.Concurrent;
|
||||||
|
using SixLabors.ImageSharp;
|
||||||
|
using SixLabors.ImageSharp.Formats.Png;
|
||||||
|
using SixLabors.ImageSharp.PixelFormats;
|
||||||
|
using SixLabors.ImageSharp.Processing;
|
||||||
|
using StitcherApi.Services.Utilities;
|
||||||
|
|
||||||
|
namespace StitcherApi.Services.Streaming;
|
||||||
|
|
||||||
|
public class StreamingProcessor
|
||||||
|
{
|
||||||
|
private readonly string _assetPath;
|
||||||
|
private readonly ILogger<StreamingProcessor> _logger;
|
||||||
|
private const int TILE_SIZE = 720;
|
||||||
|
private const int BAND_HEIGHT = TILE_SIZE;
|
||||||
|
|
||||||
|
public StreamingProcessor(string assetPath, ILogger<StreamingProcessor> logger)
|
||||||
|
{
|
||||||
|
_assetPath = assetPath;
|
||||||
|
_logger = logger;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<byte[]> ProcessAsync(StitchRequest request)
|
||||||
|
{
|
||||||
|
_logger.LogDebug(
|
||||||
|
"Starting streaming processor for crop size {W}x{H}",
|
||||||
|
request.CropW,
|
||||||
|
request.CropH
|
||||||
|
);
|
||||||
|
|
||||||
|
ConcurrentDictionary<(int, int), Image<Rgba32>> tileCache = await LoadRequiredTilesAsync(
|
||||||
|
request
|
||||||
|
);
|
||||||
|
|
||||||
|
using MemoryStream memoryStream = new MemoryStream();
|
||||||
|
PngEncoder pngEncoder = new PngEncoder { CompressionLevel = PngCompressionLevel.BestSpeed };
|
||||||
|
|
||||||
|
for (int bandY = 0; bandY < request.CropH; bandY += BAND_HEIGHT)
|
||||||
|
{
|
||||||
|
int currentBandHeight = Math.Min(BAND_HEIGHT, request.CropH - bandY);
|
||||||
|
using Image<Rgba32> bandImage = new Image<Rgba32>(request.CropW, currentBandHeight);
|
||||||
|
ComposeBandOptimized(bandImage, bandY, request, tileCache);
|
||||||
|
await bandImage.SaveAsPngAsync(memoryStream, pngEncoder);
|
||||||
|
}
|
||||||
|
|
||||||
|
return memoryStream.ToArray();
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task<ConcurrentDictionary<(int, int), Image<Rgba32>>> LoadRequiredTilesAsync(
|
||||||
|
StitchRequest r
|
||||||
|
)
|
||||||
|
{
|
||||||
|
ConcurrentDictionary<(int, int), Image<Rgba32>> cache = new();
|
||||||
|
List<Task> loadTasks = new();
|
||||||
|
|
||||||
|
for (int row = r.StartTileRow; row <= r.EndTileRow; row++)
|
||||||
|
{
|
||||||
|
for (int col = r.StartTileCol; col <= r.EndTileCol; col++)
|
||||||
|
{
|
||||||
|
int tileRow = row;
|
||||||
|
int tileCol = col;
|
||||||
|
loadTasks.Add(
|
||||||
|
Task.Run(async () =>
|
||||||
|
{
|
||||||
|
string tileFilePath = Path.Combine(
|
||||||
|
_assetPath,
|
||||||
|
TileHelper.GetTileFileName(tileRow, tileCol)
|
||||||
|
);
|
||||||
|
if (!File.Exists(tileFilePath))
|
||||||
|
throw new FileNotFoundException($"Asset not found: {tileFilePath}");
|
||||||
|
cache[(tileRow, tileCol)] = await Image.LoadAsync<Rgba32>(tileFilePath);
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
await Task.WhenAll(loadTasks);
|
||||||
|
return cache;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void ComposeBandOptimized(
|
||||||
|
Image<Rgba32> bandImage,
|
||||||
|
int bandY,
|
||||||
|
StitchRequest r,
|
||||||
|
ConcurrentDictionary<(int, int), Image<Rgba32>> tileCache
|
||||||
|
)
|
||||||
|
{
|
||||||
|
int bandAbsoluteY = r.CropY + bandY;
|
||||||
|
int currentTileRow = r.MinRow + (bandAbsoluteY / TILE_SIZE);
|
||||||
|
|
||||||
|
for (int tileCol = r.StartTileCol; tileCol <= r.EndTileCol; tileCol++)
|
||||||
|
{
|
||||||
|
if (tileCache.TryGetValue((currentTileRow, tileCol), out Image<Rgba32>? tileImage))
|
||||||
|
{
|
||||||
|
int tileOriginX = (tileCol - r.MinCol) * TILE_SIZE;
|
||||||
|
int tileOriginY = (currentTileRow - r.MinRow) * TILE_SIZE;
|
||||||
|
|
||||||
|
Rectangle bandRect = new Rectangle(
|
||||||
|
r.CropX,
|
||||||
|
bandAbsoluteY,
|
||||||
|
r.CropW,
|
||||||
|
bandImage.Height
|
||||||
|
);
|
||||||
|
Rectangle tileRect = new Rectangle(tileOriginX, tileOriginY, TILE_SIZE, TILE_SIZE);
|
||||||
|
Rectangle intersection = Rectangle.Intersect(tileRect, bandRect);
|
||||||
|
|
||||||
|
if (!intersection.IsEmpty)
|
||||||
|
{
|
||||||
|
Point sourcePoint = new Point(
|
||||||
|
intersection.X - tileOriginX,
|
||||||
|
intersection.Y - tileOriginY
|
||||||
|
);
|
||||||
|
Point destPoint = new Point(
|
||||||
|
intersection.X - r.CropX,
|
||||||
|
intersection.Y - bandAbsoluteY
|
||||||
|
);
|
||||||
|
Rectangle cropRectangle = new Rectangle(sourcePoint, intersection.Size);
|
||||||
|
|
||||||
|
bandImage.Mutate(ctx =>
|
||||||
|
ctx.DrawImage(tileImage, destPoint, cropRectangle, new GraphicsOptions())
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -6,6 +6,6 @@
|
||||||
</PropertyGroup>
|
</PropertyGroup>
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<PackageReference Include="Microsoft.AspNetCore.OpenApi" Version="8.0.5" />
|
<PackageReference Include="Microsoft.AspNetCore.OpenApi" Version="8.0.5" />
|
||||||
<PackageReference Include="SixLabors.ImageSharp" Version="3.1.10" />
|
<PackageReference Include="SixLabors.ImageSharp" Version="3.1.11" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
</Project>
|
</Project>
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,8 @@
|
||||||
"Logging": {
|
"Logging": {
|
||||||
"LogLevel": {
|
"LogLevel": {
|
||||||
"Default": "Information",
|
"Default": "Information",
|
||||||
"Microsoft.AspNetCore": "Warning"
|
"Microsoft.AspNetCore": "Warning",
|
||||||
|
"StitcherApi": "Debug"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue