using SkiaSharp; using StitcherApi.Models; using StitcherApi.Services.Utilities; 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; public ImageService(IConfiguration configuration, ILogger logger) { _assetPath = configuration["AssetPath"] ?? throw new InvalidOperationException("AssetPath is not configured."); _logger = logger; } public 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 ); return data.ToArray(); } }