Compare commits
No commits in common. "main" and "0.1" have entirely different histories.
9 changed files with 144 additions and 573 deletions
3
.gitignore
vendored
3
.gitignore
vendored
|
|
@ -1,3 +1,2 @@
|
||||||
/bin/
|
/bin/
|
||||||
/obj/
|
/obj/
|
||||||
/test/benchmark_output/
|
|
||||||
|
|
@ -1,4 +1,3 @@
|
||||||
using SkiaSharp;
|
|
||||||
using StitcherApi.Models;
|
using StitcherApi.Models;
|
||||||
using StitcherApi.Services.Utilities;
|
using StitcherApi.Services.Utilities;
|
||||||
|
|
||||||
|
|
@ -6,247 +5,59 @@ namespace StitcherApi.Services;
|
||||||
|
|
||||||
public class ImageService : IImageService
|
public class ImageService : IImageService
|
||||||
{
|
{
|
||||||
private const int TileDimension = 720;
|
private readonly ImageProcessor _processor;
|
||||||
private const long HighQualityMemoryThreshold = 512 * 1024 * 1024 * 3;
|
private const int TILE_SIZE = 720;
|
||||||
private readonly string _assetPath;
|
|
||||||
private readonly ILogger<ImageService> _logger;
|
|
||||||
|
|
||||||
public ImageService(IConfiguration configuration, ILogger<ImageService> logger)
|
public ImageService(IConfiguration configuration)
|
||||||
{
|
{
|
||||||
_assetPath =
|
string assetPath =
|
||||||
configuration["AssetPath"]
|
configuration["AssetPath"]
|
||||||
?? throw new InvalidOperationException("AssetPath is not configured.");
|
?? throw new InvalidOperationException("AssetPath is not configured.");
|
||||||
_logger = logger;
|
_processor = new ImageProcessor(assetPath);
|
||||||
}
|
}
|
||||||
|
|
||||||
public Task<byte[]> GenerateImageAsync(GenerateImageRequest request)
|
public async Task<byte[]> GenerateImageAsync(GenerateImageRequest request)
|
||||||
{
|
{
|
||||||
return Task.Run(() =>
|
// 1. Delegate parsing to the CoordinateParser
|
||||||
{
|
(int minRow, int minCol, int maxRow, int maxCol) = CoordinateParser.ParseCanvasRect(
|
||||||
try
|
request.CanvasRect
|
||||||
{
|
|
||||||
_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<byte>();
|
|
||||||
}
|
|
||||||
|
|
||||||
_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();
|
|
||||||
|
// 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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
94
Services/Utilities/ImageProcessor.cs
Normal file
94
Services/Utilities/ImageProcessor.cs
Normal file
|
|
@ -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<byte[]> StitchAndCropAsync(StitchRequest stitchRequest)
|
||||||
|
{
|
||||||
|
using Image<Rgba32> 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
|
||||||
|
);
|
||||||
|
|
@ -6,7 +6,6 @@
|
||||||
</PropertyGroup>
|
</PropertyGroup>
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<PackageReference Include="Microsoft.AspNetCore.OpenApi" Version="8.0.5" />
|
<PackageReference Include="Microsoft.AspNetCore.OpenApi" Version="8.0.5" />
|
||||||
<PackageReference Include="SkiaSharp" Version="3.119.0" />
|
<PackageReference Include="SixLabors.ImageSharp" Version="3.1.10" />
|
||||||
<PackageReference Include="SkiaSharp.NativeAssets.Linux.NoDependencies" Version="3.119.0" />
|
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
</Project>
|
</Project>
|
||||||
|
|
|
||||||
|
|
@ -2,8 +2,7 @@
|
||||||
"Logging": {
|
"Logging": {
|
||||||
"LogLevel": {
|
"LogLevel": {
|
||||||
"Default": "Information",
|
"Default": "Information",
|
||||||
"Microsoft.AspNetCore": "Warning",
|
"Microsoft.AspNetCore": "Warning"
|
||||||
"StitcherApi": "Debug"
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -6,4 +6,4 @@
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"AllowedHosts": "*"
|
"AllowedHosts": "*"
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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();
|
|
||||||
|
|
@ -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"
|
|
||||||
}
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue