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:
gelaws-hub 2025-08-01 20:18:01 +07:00
parent 8cec6e92c5
commit 4ed4eea462
7 changed files with 210 additions and 46 deletions

View file

@ -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);
} }
} }

View file

@ -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
);

View 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
);

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

View file

@ -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>

View file

@ -2,7 +2,8 @@
"Logging": { "Logging": {
"LogLevel": { "LogLevel": {
"Default": "Information", "Default": "Information",
"Microsoft.AspNetCore": "Warning" "Microsoft.AspNetCore": "Warning",
"StitcherApi": "Debug"
} }
} }
} }

View file

@ -6,4 +6,4 @@
} }
}, },
"AllowedHosts": "*" "AllowedHosts": "*"
} }