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/Services/ImageService.cs b/Services/ImageService.cs index 0f8651e..9480c1e 100644 --- a/Services/ImageService.cs +++ b/Services/ImageService.cs @@ -1,3 +1,4 @@ +using SkiaSharp; using StitcherApi.Models; using StitcherApi.Services.Utilities; @@ -5,59 +6,247 @@ namespace StitcherApi.Services; public class ImageService : IImageService { - private readonly ImageProcessor _processor; - private const int TILE_SIZE = 720; + private const int TileDimension = 720; + private const long HighQualityMemoryThreshold = 512 * 1024 * 1024 * 3; + private readonly string _assetPath; + private readonly ILogger _logger; - public ImageService(IConfiguration configuration) + public ImageService(IConfiguration configuration, ILogger logger) { - string assetPath = + _assetPath = configuration["AssetPath"] ?? throw new InvalidOperationException("AssetPath is not configured."); - _processor = new ImageProcessor(assetPath); + _logger = logger; } - public async Task GenerateImageAsync(GenerateImageRequest request) + public 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 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); - - if (cropW <= 0 || cropH <= 0) + return Task.Run(() => { - throw new ArgumentException("Calculated crop dimensions are invalid."); + try + { + _logger.LogInformation( + "Starting image generation for canvas_rect: {CanvasRect}", + request.CanvasRect + ); + + 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); + + 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 byte[] GenerateWithHighQuality( + int cropX, + int cropY, + int cropW, + int cropH, + int outputW, + int outputH, + int startRow, + int startCol + ) + { + using var cropBufferBitmap = new SKBitmap(cropW, cropH); + using var cropBufferCanvas = new SKCanvas(cropBufferBitmap); + + 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); + } - 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); + 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; - // 3. Create a parameter object for the processor - StitchRequest stitchRequest = new StitchRequest( - minRow, - minCol, - startTileRow, - startTileCol, - endTileRow, - endTileCol, - cropX, - cropY, - cropW, - cropH, - request.OutputScale + 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 ); - - // 4. Delegate image processing work - return await _processor.StitchAndCropAsync(stitchRequest); + 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/Services/Utilities/ImageProcessor.cs b/Services/Utilities/ImageProcessor.cs deleted file mode 100644 index 393f012..0000000 --- a/Services/Utilities/ImageProcessor.cs +++ /dev/null @@ -1,94 +0,0 @@ -using SixLabors.ImageSharp; -using SixLabors.ImageSharp.PixelFormats; -using SixLabors.ImageSharp.Processing; - -namespace StitcherApi.Services.Utilities; - -internal class ImageProcessor -{ - private readonly string _assetPath; - private const int TILE_SIZE = 720; - - public ImageProcessor(string assetPath) - { - _assetPath = assetPath; - } - - public async Task StitchAndCropAsync(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(); - } -} - -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/StitcherApi.csproj b/StitcherApi.csproj index a437c5f..ea116c0 100644 --- a/StitcherApi.csproj +++ b/StitcherApi.csproj @@ -6,6 +6,7 @@ - + + 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 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