first commit

This commit is contained in:
gelaws-hub 2025-07-31 19:39:06 +07:00
commit b344b6a03f
16 changed files with 405 additions and 0 deletions

View file

@ -0,0 +1,8 @@
using StitcherApi.Models;
namespace StitcherApi.Services;
public interface IImageService
{
Task<byte[]> GenerateImageAsync(GenerateImageRequest request);
}

61
Services/ImageService.cs Normal file
View file

@ -0,0 +1,61 @@
using StitcherApi.Models;
using StitcherApi.Services.Utilities;
namespace StitcherApi.Services;
public class ImageService : IImageService
{
private readonly ImageProcessor _processor;
private const int TILE_SIZE = 720;
public ImageService(IConfiguration configuration)
{
string assetPath =
configuration["AssetPath"]
?? throw new InvalidOperationException("AssetPath is not configured.");
_processor = new ImageProcessor(assetPath);
}
public async Task<byte[]> GenerateImageAsync(GenerateImageRequest request)
{
// 1. Delegate parsing to the CoordinateParser
var (minRow, minCol, maxRow, maxCol) = CoordinateParser.ParseCanvasRect(request.CanvasRect);
// 2. Perform high-level calculations
var stitchedCanvasWidth = (maxCol - minCol + 1) * TILE_SIZE;
var 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.");
}
var startTileCol = minCol + (cropX / TILE_SIZE);
var endTileCol = minCol + ((cropX + cropW - 1) / TILE_SIZE);
var startTileRow = minRow + (cropY / TILE_SIZE);
var endTileRow = minRow + ((cropY + cropH - 1) / TILE_SIZE);
// 3. Create a parameter object for the processor
var 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);
}
}

View file

@ -0,0 +1,52 @@
using System.Text.RegularExpressions;
namespace StitcherApi.Services.Utilities;
public static class CoordinateParser
{
private static readonly Regex CoordRegex = new(
@"([A-Z]+)(\d+)",
RegexOptions.Compiled | RegexOptions.IgnoreCase
);
public static (int MinRow, int MinCol, int MaxRow, int MaxCol) ParseCanvasRect(string rect)
{
if (string.IsNullOrEmpty(rect))
{
throw new ArgumentException("canvas_rect cannot be null or empty.");
}
var parts = rect.Split(':');
if (parts.Length != 2)
{
throw new ArgumentException("Invalid canvas_rect format.");
}
var (row1, col1) = ParseSingleCoordinate(parts[0]);
var (row2, col2) = ParseSingleCoordinate(parts[1]);
return (
Math.Min(row1, row2),
Math.Min(col1, col2),
Math.Max(row1, row2),
Math.Max(col1, col2)
);
}
private static (int Row, int Col) ParseSingleCoordinate(string coord)
{
var match = CoordRegex.Match(coord);
if (!match.Success)
{
throw new ArgumentException($"Invalid coordinate format: {coord}");
}
var rowStr = match.Groups[1].Value.ToUpper();
var colStr = match.Groups[2].Value;
int row = rowStr.Length == 1 ? rowStr[0] - 'A' : 26 + (rowStr[1] - 'A');
int col = int.Parse(colStr) - 1;
return (row, col);
}
}

View 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 var finalImage = new Image<Rgba32>(stitchRequest.CropW, stitchRequest.CropH);
for (var r = stitchRequest.StartTileRow; r <= stitchRequest.EndTileRow; r++)
{
for (var c = stitchRequest.StartTileCol; c <= stitchRequest.EndTileCol; c++)
{
var tileFileName = TileHelper.GetTileFileName(r, c);
var tileFilePath = Path.Combine(_assetPath, tileFileName);
if (!File.Exists(tileFilePath))
{
throw new FileNotFoundException($"Asset not found: {tileFileName}");
}
using var tileImage = await Image.LoadAsync(tileFilePath);
var tileOriginX = (c - stitchRequest.MinCol) * TILE_SIZE;
var tileOriginY = (r - stitchRequest.MinRow) * TILE_SIZE;
var srcX = Math.Max(0, stitchRequest.CropX - tileOriginX);
var srcY = Math.Max(0, stitchRequest.CropY - tileOriginY);
var destX = Math.Max(0, tileOriginX - stitchRequest.CropX);
var destY = Math.Max(0, tileOriginY - stitchRequest.CropY);
var overlapW = Math.Max(
0,
Math.Min(stitchRequest.CropX + stitchRequest.CropW, tileOriginX + TILE_SIZE)
- Math.Max(stitchRequest.CropX, tileOriginX)
);
var overlapH = Math.Max(
0,
Math.Min(stitchRequest.CropY + stitchRequest.CropH, tileOriginY + TILE_SIZE)
- Math.Max(stitchRequest.CropY, tileOriginY)
);
if (overlapW > 0 && overlapH > 0)
{
var 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)
{
var newWidth = (int)(stitchRequest.CropW * stitchRequest.OutputScale);
var newHeight = (int)(stitchRequest.CropH * stitchRequest.OutputScale);
finalImage.Mutate(x => x.Resize(newWidth, newHeight, KnownResamplers.Bicubic));
}
using var 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
);

View file

@ -0,0 +1,11 @@
namespace StitcherApi.Services.Utilities;
public static class TileHelper
{
public static string GetTileFileName(int row, int col)
{
string rowStr =
row < 26 ? ((char)('A' + row)).ToString() : "A" + ((char)('A' + (row - 26)));
return $"{rowStr}{col + 1}.png";
}
}