diff --git a/.gitignore b/.gitignore index 682b318..0ff549a 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,2 @@ /bin/ -/obj/ -/test/benchmark_output/ \ No newline at end of file +/obj/ \ No newline at end of file diff --git a/Services/ImageService.cs b/Services/ImageService.cs index 9480c1e..0f8651e 100644 --- a/Services/ImageService.cs +++ b/Services/ImageService.cs @@ -1,4 +1,3 @@ -using SkiaSharp; using StitcherApi.Models; using StitcherApi.Services.Utilities; @@ -6,247 +5,59 @@ 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 ImageProcessor _processor; + private const int TILE_SIZE = 720; - public ImageService(IConfiguration configuration, ILogger logger) + public ImageService(IConfiguration configuration) { - _assetPath = + string assetPath = configuration["AssetPath"] ?? throw new InvalidOperationException("AssetPath is not configured."); - _logger = logger; + _processor = new ImageProcessor(assetPath); } - public Task GenerateImageAsync(GenerateImageRequest request) + public async Task GenerateImageAsync(GenerateImageRequest request) { - return Task.Run(() => - { - 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); - } - - 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 + // 1. Delegate parsing to the CoordinateParser + (int minRow, int minCol, int maxRow, int maxCol) = CoordinateParser.ParseCanvasRect( + request.CanvasRect ); - return data.ToArray(); + + // 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) + { + 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, + minCol, + startTileRow, + startTileCol, + endTileRow, + endTileCol, + cropX, + cropY, + cropW, + cropH, + request.OutputScale + ); + + // 4. Delegate image processing work + return await _processor.StitchAndCropAsync(stitchRequest); } } diff --git a/Services/Utilities/CoordinateHelper.cs b/Services/Utilities/CoordinateHelper.cs deleted file mode 100644 index f2ba8e3..0000000 --- a/Services/Utilities/CoordinateHelper.cs +++ /dev/null @@ -1,60 +0,0 @@ -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 new file mode 100644 index 0000000..393f012 --- /dev/null +++ b/Services/Utilities/ImageProcessor.cs @@ -0,0 +1,94 @@ +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 ea116c0..a437c5f 100644 --- a/StitcherApi.csproj +++ b/StitcherApi.csproj @@ -6,7 +6,6 @@ - - + diff --git a/appsettings.Development.json b/appsettings.Development.json index 4f8c73a..0c208ae 100644 --- a/appsettings.Development.json +++ b/appsettings.Development.json @@ -2,8 +2,7 @@ "Logging": { "LogLevel": { "Default": "Information", - "Microsoft.AspNetCore": "Warning", - "StitcherApi": "Debug" + "Microsoft.AspNetCore": "Warning" } } } diff --git a/appsettings.json b/appsettings.json index ec04bc1..10f68b8 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 deleted file mode 100644 index b56e61e..0000000 --- a/test/benchmark.js +++ /dev/null @@ -1,259 +0,0 @@ -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 deleted file mode 100644 index c2b1bd2..0000000 --- a/test/package.json +++ /dev/null @@ -1,12 +0,0 @@ -{ - "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