From ff0a302e9fa14d99a2d2750ea9f24a0afe1d05ef Mon Sep 17 00:00:00 2001 From: gelaws-hub Date: Fri, 1 Aug 2025 20:14:53 +0700 Subject: [PATCH 1/6] feat : add simple self-benchmark using node js with the help of LLM and upgrade SixLabors version --- .gitignore | 3 +- StitcherApi.csproj | 2 +- test/benchmark.js | 259 +++++++++++++++++++++++++++++++++++++++++++++ test/package.json | 12 +++ 4 files changed, 274 insertions(+), 2 deletions(-) create mode 100644 test/benchmark.js create mode 100644 test/package.json diff --git a/.gitignore b/.gitignore index 0ff549a..682b318 100644 --- a/.gitignore +++ b/.gitignore @@ -1,2 +1,3 @@ /bin/ -/obj/ \ No newline at end of file +/obj/ +/test/benchmark_output/ \ No newline at end of file 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/test/benchmark.js b/test/benchmark.js new file mode 100644 index 0000000..b56e61e --- /dev/null +++ b/test/benchmark.js @@ -0,0 +1,259 @@ +import fs from "fs/promises"; +import path from "path"; + +const API_ENDPOINT = "http://localhost:5229/api/image/generate"; +const NUM_REQUESTS_PER_SCENARIO = 10; +const OUTPUT_DIR = "benchmark_output"; + +const colors = { + reset: "\x1b[0m", + bright: "\x1b[1m", + dim: "\x1b[2m", + fg: { + cyan: "\x1b[36m", + green: "\x1b[32m", + yellow: "\x1b[33m", + red: "\x1b[31m", + }, +}; + +const scenarios = [ + { + name: "Small Canvas, Small Crop", + payload: { + canvas_rect: "A1:B2", + crop_offset: [0.25, 0.25], + crop_size: [0.5, 0.5], + output_scale: 1.0, + }, + }, + { + name: "Medium Canvas, Full Crop (Stitching)", + payload: { + canvas_rect: "C3:F6", + crop_offset: [0.0, 0.0], + crop_size: [1.0, 1.0], + output_scale: 0.5, + }, + }, + { + name: "Large Canvas, Small Corner Crop", + payload: { + canvas_rect: "A1:H12", + crop_offset: [0.0, 0.0], + crop_size: [0.1, 0.1], + output_scale: 1.0, + }, + }, + { + name: "Large Canvas, Center Crop", + payload: { + canvas_rect: "A1:AE55", + crop_offset: [0.45, 0.45], + crop_size: [0.1, 0.1], + output_scale: 1.0, + }, + }, + { + name: "Tall Canvas, Full Crop & Scale", + payload: { + canvas_rect: "A1:A20", + crop_offset: [0.0, 0.0], + crop_size: [1.0, 1.0], + output_scale: 0.1, + }, + }, + { + name: "Wide Canvas, Full Crop & Scale", + payload: { + canvas_rect: "A1:T1", + crop_offset: [0.0, 0.0], + crop_size: [1.0, 1.0], + output_scale: 0.1, + }, + }, + { + name: "Single Tile, Full Crop", + payload: { + canvas_rect: "K16:K16", + crop_offset: [0.0, 0.0], + crop_size: [1.0, 1.0], + output_scale: 1.0, + }, + }, + { + name: "Single Tile, Partial Crop", + payload: { + canvas_rect: "K16:K16", + crop_offset: [0.1, 0.1], + crop_size: [0.25, 0.25], + output_scale: 1.0, + }, + }, + { + name: "Large Canvas, Bottom-Right Crop", + payload: { + canvas_rect: "A1:AE55", + crop_offset: [0.95, 0.95], + crop_size: [0.05, 0.05], + output_scale: 1.0, + }, + }, + { + name: "Wide Canvas, Thin Horizontal Crop", + payload: { + canvas_rect: "A1:AE10", + crop_offset: [0.0, 0.5], + crop_size: [1.0, 0.01], + output_scale: 1.0, + }, + }, + { + name: "Tall Canvas, Thin Vertical Crop", + payload: { + canvas_rect: "A1:J55", + crop_offset: [0.5, 0.0], + crop_size: [0.01, 1.0], + output_scale: 1.0, + }, + }, + { + name: "Medium Canvas, Heavy Scaling", + payload: { + canvas_rect: "D4:G8", + crop_offset: [0.0, 0.0], + crop_size: [1.0, 1.0], + output_scale: 0.05, + }, + }, + { + name: "MAXIMUM CANVAS (Streaming Test)", + payload: { + canvas_rect: "A1:AE55", + crop_offset: [0.0, 0.0], + crop_size: [1.0, 1.0], + output_scale: 0.01, + }, + }, +]; + +const calculateStats = (times) => { + if (times.length === 0) return null; + const sum = times.reduce((a, b) => a + b, 0); + const avg = sum / times.length; + const stdDev = Math.sqrt( + times.map((x) => Math.pow(x - avg, 2)).reduce((a, b) => a + b, 0) / + times.length + ); + const sorted = [...times].sort((a, b) => a - b); + return { + avg: avg, + stdDev: stdDev, + median: sorted[Math.floor(sorted.length / 2)], + min: sorted[0], + max: sorted[sorted.length - 1], + throughput: 1000 / avg, + }; +}; + +async function runBenchmark() { + console.log( + `${colors.bright}${colors.fg.cyan}--- Starting Image Stitcher API Benchmark ---${colors.reset}` + ); + + try { + await fs.mkdir(OUTPUT_DIR, { recursive: true }); + } catch (e) { + console.error( + `${colors.fg.red}Error creating output directory: ${e.message}${colors.reset}` + ); + return; + } + + const allResults = []; + + for (const scenario of scenarios) { + console.log( + `\n${colors.fg.yellow}Running Scenario: '${scenario.name}' (${NUM_REQUESTS_PER_SCENARIO} requests)...${colors.reset}` + ); + const responseTimes = []; + + for (let i = 0; i < NUM_REQUESTS_PER_SCENARIO; i++) { + try { + const startTime = performance.now(); + const response = await fetch(API_ENDPOINT, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(scenario.payload), + }); + const endTime = performance.now(); + + if (response.ok) { + responseTimes.push(endTime - startTime); + if (i === 0) { + const arrayBuffer = await response.arrayBuffer(); + const buffer = Buffer.from(arrayBuffer); + const fileName = `${scenario.name + .replace(/[\s(),]/g, "_") + .toLowerCase()}.png`; + const filePath = path.join(OUTPUT_DIR, fileName); + await fs.writeFile(filePath, buffer); + console.log( + ` ${colors.dim}Saved output image to '${filePath}'${colors.reset}` + ); + } else { + await response.arrayBuffer(); + } + } else { + console.log( + ` ${colors.fg.red}Request ${i + 1} failed with status ${ + response.status + }${colors.reset}` + ); + } + } catch (e) { + console.error( + ` ${colors.fg.red}Request ${i + 1} failed with an exception: ${ + e.message + }${colors.reset}` + ); + + break; + } + } + + const stats = calculateStats(responseTimes); + if (stats) { + allResults.push({ name: scenario.name, stats }); + console.log( + ` ${colors.fg.green}Average Latency: ${stats.avg.toFixed(2)} ms${ + colors.reset + }` + ); + } + } + + console.log( + `\n${colors.bright}${colors.fg.cyan}--- Benchmark Summary ---${colors.reset}` + ); + for (const result of allResults) { + console.log(`\n${colors.bright}Scenario: ${result.name}${colors.reset}`); + const { stats } = result; + console.log( + ` Avg Latency: ${stats.avg.toFixed(2)} ms (+/- ${stats.stdDev.toFixed( + 2 + )} ms)` + ); + console.log( + ` Details (ms): Median: ${stats.median.toFixed( + 2 + )} | Min: ${stats.min.toFixed(2)} | Max: ${stats.max.toFixed(2)}` + ); + console.log(` Avg Throughput: ${stats.throughput.toFixed(2)} req/s`); + } + console.log( + `\n${colors.bright}${colors.fg.cyan}--- Benchmark Complete ---${colors.reset}` + ); +} + +runBenchmark(); diff --git a/test/package.json b/test/package.json new file mode 100644 index 0000000..c2b1bd2 --- /dev/null +++ b/test/package.json @@ -0,0 +1,12 @@ +{ + "name": "api-benchmark", + "version": "1.0.0", + "description": "A benchmark script for the Image Stitcher API.", + "main": "benchmark.js", + "type": "module", + "scripts": { + "start": "node benchmark.js" + }, + "author": "", + "license": "ISC" +} \ No newline at end of file From 4ed4eea4628ef5ce0f9647a94bb7240cc0a823ea Mon Sep 17 00:00:00 2001 From: gelaws-hub Date: Fri, 1 Aug 2025 20:18:01 +0700 Subject: [PATCH 2/6] 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 --- Services/ImageService.cs | 82 ++++++++---- ...ageProcessor.cs => FastStitchProcessor.cs} | 27 ++-- Services/Utilities/StitchRequest.cs | 15 +++ Services/Utilities/StreamingProcessor.cs | 125 ++++++++++++++++++ StitcherApi.csproj | 2 +- appsettings.Development.json | 3 +- appsettings.json | 2 +- 7 files changed, 210 insertions(+), 46 deletions(-) rename Services/Utilities/{ImageProcessor.cs => FastStitchProcessor.cs} (84%) create mode 100644 Services/Utilities/StitchRequest.cs create mode 100644 Services/Utilities/StreamingProcessor.cs 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 From dd9f0d9cbe13b2e2fc8fd4d81a32581925d54058 Mon Sep 17 00:00:00 2001 From: gelaws-hub Date: Fri, 1 Aug 2025 21:11:23 +0700 Subject: [PATCH 3/6] feat : put back the original logic, the streaming mechanism didn't produce a result as expected --- Services/ImageService.cs | 39 +----- ...tStitchProcessor.cs => StitchProcessor.cs} | 9 +- Services/Utilities/StreamingProcessor.cs | 125 ------------------ 3 files changed, 9 insertions(+), 164 deletions(-) rename Services/Utilities/{FastStitchProcessor.cs => StitchProcessor.cs} (92%) delete mode 100644 Services/Utilities/StreamingProcessor.cs diff --git a/Services/ImageService.cs b/Services/ImageService.cs index 924e993..6692d75 100644 --- a/Services/ImageService.cs +++ b/Services/ImageService.cs @@ -1,6 +1,4 @@ using StitcherApi.Models; -using StitcherApi.Services.Fast; -using StitcherApi.Services.Streaming; using StitcherApi.Services.Utilities; namespace StitcherApi.Services; @@ -8,56 +6,29 @@ namespace StitcherApi.Services; public class ImageService : IImageService { private readonly ILogger _logger; - private readonly FastProcessor _fastProcessor; - private readonly StreamingProcessor _streamingProcessor; - - 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; + private readonly StitchProcessor _stitchProcessor; public ImageService(IConfiguration config, ILoggerFactory loggerFactory) { _logger = loggerFactory.CreateLogger(); string assetPath = config["AssetPath"] ?? throw new InvalidOperationException("AssetPath not configured."); - - _fastProcessor = new FastProcessor(assetPath, loggerFactory.CreateLogger()); - _streamingProcessor = new StreamingProcessor( + _stitchProcessor = new StitchProcessor( assetPath, - loggerFactory.CreateLogger() + loggerFactory.CreateLogger() ); } public async Task GenerateImageAsync(GenerateImageRequest request) { + _logger.LogInformation("Processing request with the fast processor..."); (int minRow, int minCol, int maxRow, int maxCol) = CoordinateParser.ParseCanvasRect( request.CanvasRect ); - 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); - } + return await _stitchProcessor.ProcessAsync(stitchRequest); } private StitchRequest CreateStitchRequest( diff --git a/Services/Utilities/FastStitchProcessor.cs b/Services/Utilities/StitchProcessor.cs similarity index 92% rename from Services/Utilities/FastStitchProcessor.cs rename to Services/Utilities/StitchProcessor.cs index 78f2c20..b36bf37 100644 --- a/Services/Utilities/FastStitchProcessor.cs +++ b/Services/Utilities/StitchProcessor.cs @@ -1,17 +1,16 @@ using SixLabors.ImageSharp; using SixLabors.ImageSharp.PixelFormats; using SixLabors.ImageSharp.Processing; -using StitcherApi.Services.Utilities; -namespace StitcherApi.Services.Fast; +namespace StitcherApi.Services.Utilities; -public class FastProcessor +public class StitchProcessor { private readonly string _assetPath; - private readonly ILogger _logger; + private readonly ILogger _logger; private const int TILE_SIZE = 720; - public FastProcessor(string assetPath, ILogger logger) + public StitchProcessor(string assetPath, ILogger logger) { _assetPath = assetPath; _logger = logger; diff --git a/Services/Utilities/StreamingProcessor.cs b/Services/Utilities/StreamingProcessor.cs deleted file mode 100644 index 67fa1f2..0000000 --- a/Services/Utilities/StreamingProcessor.cs +++ /dev/null @@ -1,125 +0,0 @@ -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()) - ); - } - } - } - } -} From c390415b6ed06fb0d5a5f5b1fd363d4aa0586859 Mon Sep 17 00:00:00 2001 From: gelaws-hub Date: Fri, 1 Aug 2025 23:04:42 +0700 Subject: [PATCH 4/6] feat : rework with the logic again, use skiasharp instead --- Services/ImageService.cs | 274 +++++++++++++++++++++---- Services/Utilities/CoordinateHelper.cs | 60 ++++++ StitcherApi.csproj | 2 +- 3 files changed, 290 insertions(+), 46 deletions(-) create mode 100644 Services/Utilities/CoordinateHelper.cs diff --git a/Services/ImageService.cs b/Services/ImageService.cs index 6692d75..9480c1e 100644 --- a/Services/ImageService.cs +++ b/Services/ImageService.cs @@ -1,3 +1,4 @@ +using SkiaSharp; using StitcherApi.Models; using StitcherApi.Services.Utilities; @@ -5,64 +6,247 @@ namespace StitcherApi.Services; public class ImageService : IImageService { + private const int TileDimension = 720; + private const long HighQualityMemoryThreshold = 512 * 1024 * 1024 * 3; + private readonly string _assetPath; private readonly ILogger _logger; - private readonly StitchProcessor _stitchProcessor; - public ImageService(IConfiguration config, ILoggerFactory loggerFactory) + public ImageService(IConfiguration configuration, ILogger logger) { - _logger = loggerFactory.CreateLogger(); - string assetPath = - config["AssetPath"] ?? throw new InvalidOperationException("AssetPath not configured."); - _stitchProcessor = new StitchProcessor( - assetPath, - loggerFactory.CreateLogger() - ); + _assetPath = + configuration["AssetPath"] + ?? throw new InvalidOperationException("AssetPath is not configured."); + _logger = logger; } - public async Task GenerateImageAsync(GenerateImageRequest request) + public Task GenerateImageAsync(GenerateImageRequest request) { - _logger.LogInformation("Processing request with the fast processor..."); - (int minRow, int minCol, int maxRow, int maxCol) = CoordinateParser.ParseCanvasRect( - request.CanvasRect - ); + return Task.Run(() => + { + try + { + _logger.LogInformation( + "Starting image generation for canvas_rect: {CanvasRect}", + request.CanvasRect + ); - StitchRequest stitchRequest = CreateStitchRequest(request, minRow, minCol, maxRow, maxCol); + var (startRow, endRow, startCol, endCol) = CoordinateHelper.ParseCanvasRect( + request.CanvasRect + ); + int canvasWidth = (endCol - startCol + 1) * TileDimension; + int canvasHeight = (endRow - startRow + 1) * TileDimension; + int cropX = (int)(request.CropOffset[0] * canvasWidth); + int cropY = (int)(request.CropOffset[1] * canvasHeight); + int cropW = (int)(request.CropSize[0] * canvasWidth); + int cropH = (int)(request.CropSize[1] * canvasHeight); - return await _stitchProcessor.ProcessAsync(stitchRequest); + int outputW = (int)(cropW * request.OutputScale); + int outputH = (int)(cropH * request.OutputScale); + if (outputW <= 0 || outputH <= 0) + { + _logger.LogWarning( + "Output dimensions are zero or negative ({Width}x{Height}). Returning empty byte array.", + outputW, + outputH + ); + return Array.Empty(); + } + + _logger.LogDebug("Calculated final dimensions: {Width}x{Height}", outputW, outputH); + + long requiredMemory = (long)cropW * cropH * 4; + if (requiredMemory <= HighQualityMemoryThreshold) + { + _logger.LogInformation( + "Using high-quality rendering path (required memory: {Memory}MB)", + requiredMemory / (1024 * 1024) + ); + return GenerateWithHighQuality( + cropX, + cropY, + cropW, + cropH, + outputW, + outputH, + startRow, + startCol + ); + } + else + { + _logger.LogWarning( + "Required memory ({Memory}MB) exceeds threshold. Using low-memory fallback path. Image quality may be reduced.", + requiredMemory / (1024 * 1024) + ); + return GenerateWithLowMemory( + cropX, + cropY, + cropW, + cropH, + outputW, + outputH, + startRow, + startCol, + request.OutputScale + ); + } + } + catch (Exception ex) + { + _logger.LogError( + ex, + "An unhandled exception occurred during image generation for request: {@Request}", + request + ); + throw; + } + }); } - private StitchRequest CreateStitchRequest( - GenerateImageRequest request, - int minRow, - int minCol, - int maxRow, - int maxCol + private byte[] GenerateWithHighQuality( + int cropX, + int cropY, + int cropW, + int cropH, + int outputW, + int outputH, + int startRow, + int startCol ) { - 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; + using var cropBufferBitmap = new SKBitmap(cropW, cropH); + using var cropBufferCanvas = new SKCanvas(cropBufferBitmap); - return new StitchRequest( - minRow, - minCol, - startTileRow, - startTileCol, - endTileRow, - endTileCol, - cropX, - cropY, - cropW, - cropH, - request.OutputScale + DrawTilesToCanvas(cropBufferCanvas, cropX, cropY, cropW, cropH, startRow, startCol); + + using var finalBitmap = new SKBitmap(outputW, outputH); + + var sampling = new SKSamplingOptions(SKCubicResampler.Mitchell); + cropBufferBitmap.ScalePixels(finalBitmap, sampling); + + return EncodeBitmap(finalBitmap); + } + + private byte[] GenerateWithLowMemory( + int cropX, + int cropY, + int cropW, + int cropH, + int outputW, + int outputH, + int startRow, + int startCol, + float scale + ) + { + using var finalBitmap = new SKBitmap(outputW, outputH); + using var finalCanvas = new SKCanvas(finalBitmap); + var sampling = new SKSamplingOptions(SKCubicResampler.Mitchell); + + int firstTileCol = cropX / TileDimension; + int lastTileCol = (cropX + cropW - 1) / TileDimension; + int firstTileRow = cropY / TileDimension; + int lastTileRow = (cropY + cropH - 1) / TileDimension; + + for (int r = firstTileRow; r <= lastTileRow; r++) + { + for (int c = firstTileCol; c <= lastTileCol; c++) + { + var tilePath = Path.Combine( + _assetPath, + $"{CoordinateHelper.IndexToRow(startRow + r)}{startCol + c + 1}.png" + ); + using var tileBitmap = SKBitmap.Decode(tilePath); + if (tileBitmap == null) + continue; + using var tileImage = SKImage.FromBitmap(tileBitmap); + + int tileCanvasX = c * TileDimension; + int tileCanvasY = r * TileDimension; + int intersectX = Math.Max(cropX, tileCanvasX); + int intersectY = Math.Max(cropY, tileCanvasY); + int intersectEndX = Math.Min(cropX + cropW, tileCanvasX + TileDimension); + int intersectEndY = Math.Min(cropY + cropH, tileCanvasY + TileDimension); + + var sourceRect = SKRect.Create( + intersectX - tileCanvasX, + intersectY - tileCanvasY, + intersectEndX - intersectX, + intersectEndY - intersectY + ); + var destRect = SKRect.Create( + (intersectX - cropX) * scale, + (intersectY - cropY) * scale, + (intersectEndX - intersectX) * scale, + (intersectEndY - intersectY) * scale + ); + + finalCanvas.DrawImage(tileImage, sourceRect, destRect, sampling); + } + } + return EncodeBitmap(finalBitmap); + } + + private void DrawTilesToCanvas( + SKCanvas canvas, + int cropX, + int cropY, + int cropW, + int cropH, + int startRow, + int startCol + ) + { + int firstTileCol = cropX / TileDimension; + int lastTileCol = (cropX + cropW - 1) / TileDimension; + int firstTileRow = cropY / TileDimension; + int lastTileRow = (cropY + cropH - 1) / TileDimension; + + for (int r = firstTileRow; r <= lastTileRow; r++) + { + for (int c = firstTileCol; c <= lastTileCol; c++) + { + var tilePath = Path.Combine( + _assetPath, + $"{CoordinateHelper.IndexToRow(startRow + r)}{startCol + c + 1}.png" + ); + using var tileBitmap = SKBitmap.Decode(tilePath); + if (tileBitmap == null) + continue; + + int tileCanvasX = c * TileDimension; + int tileCanvasY = r * TileDimension; + int intersectX = Math.Max(cropX, tileCanvasX); + int intersectY = Math.Max(cropY, tileCanvasY); + int intersectEndX = Math.Min(cropX + cropW, tileCanvasX + TileDimension); + int intersectEndY = Math.Min(cropY + cropH, tileCanvasY + TileDimension); + + var sourceRect = SKRect.Create( + intersectX - tileCanvasX, + intersectY - tileCanvasY, + intersectEndX - intersectX, + intersectEndY - intersectY + ); + var destRect = SKRect.Create( + intersectX - cropX, + intersectY - cropY, + intersectEndX - intersectX, + intersectEndY - intersectY + ); + + canvas.DrawBitmap(tileBitmap, sourceRect, destRect); + } + } + } + + private byte[] EncodeBitmap(SKBitmap bitmap) + { + using var image = SKImage.FromBitmap(bitmap); + using var data = image.Encode(SKEncodedImageFormat.Png, 100); + _logger.LogInformation( + "Image generation successful. Returning {ByteCount} bytes.", + data.Size ); + return data.ToArray(); } } diff --git a/Services/Utilities/CoordinateHelper.cs b/Services/Utilities/CoordinateHelper.cs new file mode 100644 index 0000000..f2ba8e3 --- /dev/null +++ b/Services/Utilities/CoordinateHelper.cs @@ -0,0 +1,60 @@ +using System.Text.RegularExpressions; + +namespace StitcherApi.Services.Utilities; + +public static class CoordinateHelper +{ + private static readonly Regex CoordRegex = new(@"([A-Z]+)(\d+)", RegexOptions.Compiled); + + public static (int startRow, int endRow, int startCol, int endCol) ParseCanvasRect( + string rectStr + ) + { + var parts = rectStr.Split(':'); + if (parts.Length != 2) + { + throw new ArgumentException("Invalid canvas_rect format. Expected format 'A1:H12'."); + } + + var (r1, c1) = ParseSingleCoordinate(parts[0]); + var (r2, c2) = ParseSingleCoordinate(parts[1]); + + return (Math.Min(r1, r2), Math.Max(r1, r2), Math.Min(c1, c2), Math.Max(c1, c2)); + } + + public static string IndexToRow(int index) + { + index++; + var result = ""; + while (index > 0) + { + int remainder = (index - 1) % 26; + result = (char)('A' + remainder) + result; + index = (index - 1) / 26; + } + return result; + } + + private static (int row, int col) ParseSingleCoordinate(string coord) + { + var match = CoordRegex.Match(coord); + if (!match.Success) + { + throw new ArgumentException($"Invalid coordinate format: '{coord}'."); + } + + string rowStr = match.Groups[1].Value; + int col = int.Parse(match.Groups[2].Value) - 1; + return (RowToIndex(rowStr), col); + } + + private static int RowToIndex(string rowStr) + { + int index = 0; + foreach (char c in rowStr) + { + index = index * 26 + (c - 'A' + 1); + } + return index - 1; + } +} diff --git a/StitcherApi.csproj b/StitcherApi.csproj index a2c1ab7..1748954 100644 --- a/StitcherApi.csproj +++ b/StitcherApi.csproj @@ -6,6 +6,6 @@ - + From 695525d24de9a3863d5155386a45c880e2637fe9 Mon Sep 17 00:00:00 2001 From: gelaws-hub Date: Fri, 1 Aug 2025 23:08:22 +0700 Subject: [PATCH 5/6] chore : remove unused files --- Services/Utilities/StitchProcessor.cs | 82 --------------------------- Services/Utilities/StitchRequest.cs | 15 ----- 2 files changed, 97 deletions(-) delete mode 100644 Services/Utilities/StitchProcessor.cs delete mode 100644 Services/Utilities/StitchRequest.cs diff --git a/Services/Utilities/StitchProcessor.cs b/Services/Utilities/StitchProcessor.cs deleted file mode 100644 index b36bf37..0000000 --- a/Services/Utilities/StitchProcessor.cs +++ /dev/null @@ -1,82 +0,0 @@ -using SixLabors.ImageSharp; -using SixLabors.ImageSharp.PixelFormats; -using SixLabors.ImageSharp.Processing; - -namespace StitcherApi.Services.Utilities; - -public class StitchProcessor -{ - private readonly string _assetPath; - private readonly ILogger _logger; - private const int TILE_SIZE = 720; - - public StitchProcessor(string assetPath, ILogger logger) - { - _assetPath = assetPath; - _logger = logger; - } - - public async Task ProcessAsync(StitchRequest stitchRequest) - { - using Image finalImage = new(stitchRequest.CropW, stitchRequest.CropH); - - for (int r = stitchRequest.StartTileRow; r <= stitchRequest.EndTileRow; r++) - { - for (int c = stitchRequest.StartTileCol; c <= stitchRequest.EndTileCol; c++) - { - string tileFileName = TileHelper.GetTileFileName(r, c); - string tileFilePath = Path.Combine(_assetPath, tileFileName); - - if (!File.Exists(tileFilePath)) - { - throw new FileNotFoundException($"Asset not found: {tileFileName}"); - } - - using Image tileImage = await Image.LoadAsync(tileFilePath); - - int tileOriginX = (c - stitchRequest.MinCol) * TILE_SIZE; - int tileOriginY = (r - stitchRequest.MinRow) * TILE_SIZE; - - int srcX = Math.Max(0, stitchRequest.CropX - tileOriginX); - int srcY = Math.Max(0, stitchRequest.CropY - tileOriginY); - int destX = Math.Max(0, tileOriginX - stitchRequest.CropX); - int destY = Math.Max(0, tileOriginY - stitchRequest.CropY); - - int overlapW = Math.Max( - 0, - Math.Min(stitchRequest.CropX + stitchRequest.CropW, tileOriginX + TILE_SIZE) - - Math.Max(stitchRequest.CropX, tileOriginX) - ); - int overlapH = Math.Max( - 0, - Math.Min(stitchRequest.CropY + stitchRequest.CropH, tileOriginY + TILE_SIZE) - - Math.Max(stitchRequest.CropY, tileOriginY) - ); - - if (overlapW > 0 && overlapH > 0) - { - Rectangle sourceRect = new Rectangle(srcX, srcY, overlapW, overlapH); - finalImage.Mutate(ctx => - ctx.DrawImage( - tileImage, - new Point(destX, destY), - sourceRect, - new GraphicsOptions() - ) - ); - } - } - } - - if (stitchRequest.OutputScale > 0 && stitchRequest.OutputScale < 1.0) - { - int newWidth = (int)(stitchRequest.CropW * stitchRequest.OutputScale); - int newHeight = (int)(stitchRequest.CropH * stitchRequest.OutputScale); - finalImage.Mutate(x => x.Resize(newWidth, newHeight, KnownResamplers.Bicubic)); - } - - using MemoryStream memoryStream = new MemoryStream(); - await finalImage.SaveAsPngAsync(memoryStream); - return memoryStream.ToArray(); - } -} diff --git a/Services/Utilities/StitchRequest.cs b/Services/Utilities/StitchRequest.cs deleted file mode 100644 index 3b074fc..0000000 --- a/Services/Utilities/StitchRequest.cs +++ /dev/null @@ -1,15 +0,0 @@ -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 -); From e4fa911bd686194624bb7ecd2ba2e56882e5168c Mon Sep 17 00:00:00 2001 From: benscode Date: Thu, 13 Nov 2025 10:54:29 +0000 Subject: [PATCH 6/6] fix : add a required PackageReference for linux --- StitcherApi.csproj | 1 + 1 file changed, 1 insertion(+) diff --git a/StitcherApi.csproj b/StitcherApi.csproj index 1748954..ea116c0 100644 --- a/StitcherApi.csproj +++ b/StitcherApi.csproj @@ -7,5 +7,6 @@ +