diff --git a/Services/ImageService.cs b/Services/ImageService.cs index 0f8651e..924e993 100644 --- a/Services/ImageService.cs +++ b/Services/ImageService.cs @@ -1,49 +1,86 @@ using StitcherApi.Models; +using StitcherApi.Services.Fast; +using StitcherApi.Services.Streaming; using StitcherApi.Services.Utilities; namespace StitcherApi.Services; public class ImageService : IImageService { - private readonly ImageProcessor _processor; - private const int TILE_SIZE = 720; + private readonly ILogger _logger; + 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(); string assetPath = - configuration["AssetPath"] - ?? throw new InvalidOperationException("AssetPath is not configured."); - _processor = new ImageProcessor(assetPath); + config["AssetPath"] ?? throw new InvalidOperationException("AssetPath not configured."); + + _fastProcessor = new FastProcessor(assetPath, loggerFactory.CreateLogger()); + _streamingProcessor = new StreamingProcessor( + assetPath, + loggerFactory.CreateLogger() + ); } public async Task GenerateImageAsync(GenerateImageRequest request) { - // 1. Delegate parsing to the CoordinateParser (int minRow, int minCol, int maxRow, int maxCol) = CoordinateParser.ParseCanvasRect( 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 stitchedCanvasHeight = (maxRow - minRow + 1) * TILE_SIZE; - int cropX = (int)(request.CropOffset[0] * stitchedCanvasWidth); int cropY = (int)(request.CropOffset[1] * stitchedCanvasHeight); int cropW = (int)(request.CropSize[0] * stitchedCanvasWidth); 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) - { - 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( + return new StitchRequest( minRow, minCol, startTileRow, @@ -56,8 +93,5 @@ public class ImageService : IImageService cropH, request.OutputScale ); - - // 4. Delegate image processing work - return await _processor.StitchAndCropAsync(stitchRequest); } } diff --git a/Services/Utilities/ImageProcessor.cs b/Services/Utilities/FastStitchProcessor.cs similarity index 84% rename from Services/Utilities/ImageProcessor.cs rename to Services/Utilities/FastStitchProcessor.cs index 393f012..78f2c20 100644 --- a/Services/Utilities/ImageProcessor.cs +++ b/Services/Utilities/FastStitchProcessor.cs @@ -1,20 +1,23 @@ using SixLabors.ImageSharp; using SixLabors.ImageSharp.PixelFormats; 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 ILogger _logger; private const int TILE_SIZE = 720; - public ImageProcessor(string assetPath) + public FastProcessor(string assetPath, ILogger logger) { _assetPath = assetPath; + _logger = logger; } - public async Task StitchAndCropAsync(StitchRequest stitchRequest) + public async Task ProcessAsync(StitchRequest stitchRequest) { using Image finalImage = new(stitchRequest.CropW, stitchRequest.CropH); @@ -30,7 +33,7 @@ internal class ImageProcessor throw new FileNotFoundException($"Asset not found: {tileFileName}"); } - using Image tileImage = await Image.LoadAsync(tileFilePath); + using Image tileImage = await Image.LoadAsync(tileFilePath); int tileOriginX = (c - stitchRequest.MinCol) * TILE_SIZE; int tileOriginY = (r - stitchRequest.MinRow) * TILE_SIZE; @@ -78,17 +81,3 @@ internal class ImageProcessor 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 -); diff --git a/Services/Utilities/StitchRequest.cs b/Services/Utilities/StitchRequest.cs new file mode 100644 index 0000000..3b074fc --- /dev/null +++ b/Services/Utilities/StitchRequest.cs @@ -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 +); diff --git a/Services/Utilities/StreamingProcessor.cs b/Services/Utilities/StreamingProcessor.cs new file mode 100644 index 0000000..67fa1f2 --- /dev/null +++ b/Services/Utilities/StreamingProcessor.cs @@ -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 _logger; + private const int TILE_SIZE = 720; + private const int BAND_HEIGHT = TILE_SIZE; + + public StreamingProcessor(string assetPath, ILogger logger) + { + _assetPath = assetPath; + _logger = logger; + } + + public async Task ProcessAsync(StitchRequest request) + { + _logger.LogDebug( + "Starting streaming processor for crop size {W}x{H}", + request.CropW, + request.CropH + ); + + ConcurrentDictionary<(int, int), Image> 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 bandImage = new Image(request.CropW, currentBandHeight); + ComposeBandOptimized(bandImage, bandY, request, tileCache); + await bandImage.SaveAsPngAsync(memoryStream, pngEncoder); + } + + return memoryStream.ToArray(); + } + + private async Task>> LoadRequiredTilesAsync( + StitchRequest r + ) + { + ConcurrentDictionary<(int, int), Image> cache = new(); + List 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(tileFilePath); + }) + ); + } + } + await Task.WhenAll(loadTasks); + return cache; + } + + private void ComposeBandOptimized( + Image bandImage, + int bandY, + StitchRequest r, + ConcurrentDictionary<(int, int), Image> 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? 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()) + ); + } + } + } + } +} diff --git a/StitcherApi.csproj b/StitcherApi.csproj index a437c5f..a2c1ab7 100644 --- a/StitcherApi.csproj +++ b/StitcherApi.csproj @@ -6,6 +6,6 @@ - + diff --git a/appsettings.Development.json b/appsettings.Development.json index 0c208ae..4f8c73a 100644 --- a/appsettings.Development.json +++ b/appsettings.Development.json @@ -2,7 +2,8 @@ "Logging": { "LogLevel": { "Default": "Information", - "Microsoft.AspNetCore": "Warning" + "Microsoft.AspNetCore": "Warning", + "StitcherApi": "Debug" } } } diff --git a/appsettings.json b/appsettings.json index 10f68b8..ec04bc1 100644 --- a/appsettings.json +++ b/appsettings.json @@ -6,4 +6,4 @@ } }, "AllowedHosts": "*" -} +} \ No newline at end of file