From b344b6a03fdd2a6effd7f06a54680bf6fe5f3ebc Mon Sep 17 00:00:00 2001 From: gelaws-hub Date: Thu, 31 Jul 2025 19:39:06 +0700 Subject: [PATCH] first commit --- .config/dotnet-tools.json | 13 ++++ .gitignore | 2 + Controllers/ImageController.cs | 50 ++++++++++++++ Models/GenerateImageRequest.cs | 11 +++ Program.cs | 21 ++++++ Properties/launchSettings.json | 24 +++++++ Services/IImageService.cs | 8 +++ Services/ImageService.cs | 61 +++++++++++++++++ Services/Utilities/CoordinateParser.cs | 52 ++++++++++++++ Services/Utilities/ImageProcessor.cs | 94 ++++++++++++++++++++++++++ Services/Utilities/TileHelper.cs | 11 +++ StitcherApi.csproj | 11 +++ StitcherApi.http | 6 ++ StitcherApi.sln | 24 +++++++ appsettings.Development.json | 8 +++ appsettings.json | 9 +++ 16 files changed, 405 insertions(+) create mode 100644 .config/dotnet-tools.json create mode 100644 .gitignore create mode 100644 Controllers/ImageController.cs create mode 100644 Models/GenerateImageRequest.cs create mode 100644 Program.cs create mode 100644 Properties/launchSettings.json create mode 100644 Services/IImageService.cs create mode 100644 Services/ImageService.cs create mode 100644 Services/Utilities/CoordinateParser.cs create mode 100644 Services/Utilities/ImageProcessor.cs create mode 100644 Services/Utilities/TileHelper.cs create mode 100644 StitcherApi.csproj create mode 100644 StitcherApi.http create mode 100644 StitcherApi.sln create mode 100644 appsettings.Development.json create mode 100644 appsettings.json diff --git a/.config/dotnet-tools.json b/.config/dotnet-tools.json new file mode 100644 index 0000000..257d421 --- /dev/null +++ b/.config/dotnet-tools.json @@ -0,0 +1,13 @@ +{ + "version": 1, + "isRoot": true, + "tools": { + "csharpier": { + "version": "1.0.3", + "commands": [ + "csharpier" + ], + "rollForward": false + } + } +} \ No newline at end of file diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..0ff549a --- /dev/null +++ b/.gitignore @@ -0,0 +1,2 @@ +/bin/ +/obj/ \ No newline at end of file diff --git a/Controllers/ImageController.cs b/Controllers/ImageController.cs new file mode 100644 index 0000000..63d90b0 --- /dev/null +++ b/Controllers/ImageController.cs @@ -0,0 +1,50 @@ +using Microsoft.AspNetCore.Mvc; +using StitcherApi.Models; +using StitcherApi.Services; + +namespace StitcherApi.Controllers; + +public static class ImageController +{ + public static void MapImageEndpoints(this IEndpointRouteBuilder app) + { + var group = app.MapGroup("/api/image"); + + group.MapPost( + "/generate", + async ( + [FromBody] GenerateImageRequest request, + [FromServices] IImageService imageService + ) => + { + try + { + var imageBytes = await imageService.GenerateImageAsync(request); + return Results.File(imageBytes, "image/png"); + } + catch (FileNotFoundException ex) + { + return Results.Problem( + detail: ex.Message, + statusCode: StatusCodes.Status404NotFound + ); + } + catch (ArgumentException ex) + { + return Results.Problem( + detail: ex.Message, + statusCode: StatusCodes.Status400BadRequest + ); + } + catch (Exception) + { + // In a real app, log the exception here. + return Results.Problem( + detail: "An internal error occurred.", + statusCode: StatusCodes.Status500InternalServerError + ); + } + } + ); + } +} diff --git a/Models/GenerateImageRequest.cs b/Models/GenerateImageRequest.cs new file mode 100644 index 0000000..c9dc46b --- /dev/null +++ b/Models/GenerateImageRequest.cs @@ -0,0 +1,11 @@ +using System.Text.Json.Serialization; + +namespace StitcherApi.Models +{ + public record GenerateImageRequest( + [property: JsonPropertyName("canvas_rect")] string CanvasRect, + [property: JsonPropertyName("crop_offset")] float[] CropOffset, + [property: JsonPropertyName("crop_size")] float[] CropSize, + [property: JsonPropertyName("output_scale")] float OutputScale + ); +} diff --git a/Program.cs b/Program.cs new file mode 100644 index 0000000..f3d67eb --- /dev/null +++ b/Program.cs @@ -0,0 +1,21 @@ +using StitcherApi.Controllers; +using StitcherApi.Services; + +var builder = WebApplication.CreateBuilder(args); + +var assetPath = + Environment.GetEnvironmentVariable("ASSET_PATH_RO") + ?? Path.Combine(builder.Environment.ContentRootPath, "assets"); + +builder.Configuration["AssetPath"] = assetPath; + +builder.Services.AddSingleton(); +builder.Services.AddProblemDetails(); + +var app = builder.Build(); + +app.UseExceptionHandler(); + +app.MapImageEndpoints(); + +app.Run(); diff --git a/Properties/launchSettings.json b/Properties/launchSettings.json new file mode 100644 index 0000000..830eb60 --- /dev/null +++ b/Properties/launchSettings.json @@ -0,0 +1,24 @@ +{ + "$schema": "https://json.schemastore.org/launchsettings.json", + "profiles": { + "http": { + "commandName": "Project", + "dotnetRunMessages": true, + "launchBrowser": false, + "applicationUrl": "http://localhost:5229", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development", + "ASSET_PATH_RO": "D:\\Downloads\\tiles1705" + } + }, + "https": { + "commandName": "Project", + "dotnetRunMessages": true, + "launchBrowser": false, + "applicationUrl": "https://localhost:7293;http://localhost:5229", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + } + } +} \ No newline at end of file diff --git a/Services/IImageService.cs b/Services/IImageService.cs new file mode 100644 index 0000000..4b2b46d --- /dev/null +++ b/Services/IImageService.cs @@ -0,0 +1,8 @@ +using StitcherApi.Models; + +namespace StitcherApi.Services; + +public interface IImageService +{ + Task GenerateImageAsync(GenerateImageRequest request); +} diff --git a/Services/ImageService.cs b/Services/ImageService.cs new file mode 100644 index 0000000..73e82ba --- /dev/null +++ b/Services/ImageService.cs @@ -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 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); + } +} diff --git a/Services/Utilities/CoordinateParser.cs b/Services/Utilities/CoordinateParser.cs new file mode 100644 index 0000000..a162631 --- /dev/null +++ b/Services/Utilities/CoordinateParser.cs @@ -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); + } +} diff --git a/Services/Utilities/ImageProcessor.cs b/Services/Utilities/ImageProcessor.cs new file mode 100644 index 0000000..9c41251 --- /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 var finalImage = new Image(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 +); diff --git a/Services/Utilities/TileHelper.cs b/Services/Utilities/TileHelper.cs new file mode 100644 index 0000000..6caba60 --- /dev/null +++ b/Services/Utilities/TileHelper.cs @@ -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"; + } +} diff --git a/StitcherApi.csproj b/StitcherApi.csproj new file mode 100644 index 0000000..a437c5f --- /dev/null +++ b/StitcherApi.csproj @@ -0,0 +1,11 @@ + + + net8.0 + enable + enable + + + + + + diff --git a/StitcherApi.http b/StitcherApi.http new file mode 100644 index 0000000..fccb177 --- /dev/null +++ b/StitcherApi.http @@ -0,0 +1,6 @@ +@StitcherApi_HostAddress = http://localhost:5229 + +GET {{StitcherApi_HostAddress}}/weatherforecast/ +Accept: application/json + +### diff --git a/StitcherApi.sln b/StitcherApi.sln new file mode 100644 index 0000000..655c30c --- /dev/null +++ b/StitcherApi.sln @@ -0,0 +1,24 @@ +Microsoft Visual Studio Solution File, Format Version 12.00 +# Visual Studio Version 17 +VisualStudioVersion = 17.5.2.0 +MinimumVisualStudioVersion = 10.0.40219.1 +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StitcherApi", "StitcherApi.csproj", "{38E9E3AE-239F-C8B3-1FA4-776C2CEF90EA}" +EndProject +Global + GlobalSection(SolutionConfigurationPlatforms) = preSolution + Debug|Any CPU = Debug|Any CPU + Release|Any CPU = Release|Any CPU + EndGlobalSection + GlobalSection(ProjectConfigurationPlatforms) = postSolution + {38E9E3AE-239F-C8B3-1FA4-776C2CEF90EA}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {38E9E3AE-239F-C8B3-1FA4-776C2CEF90EA}.Debug|Any CPU.Build.0 = Debug|Any CPU + {38E9E3AE-239F-C8B3-1FA4-776C2CEF90EA}.Release|Any CPU.ActiveCfg = Release|Any CPU + {38E9E3AE-239F-C8B3-1FA4-776C2CEF90EA}.Release|Any CPU.Build.0 = Release|Any CPU + EndGlobalSection + GlobalSection(SolutionProperties) = preSolution + HideSolutionNode = FALSE + EndGlobalSection + GlobalSection(ExtensibilityGlobals) = postSolution + SolutionGuid = {64909086-2C0E-4447-B694-CC5553A0D9E3} + EndGlobalSection +EndGlobal diff --git a/appsettings.Development.json b/appsettings.Development.json new file mode 100644 index 0000000..0c208ae --- /dev/null +++ b/appsettings.Development.json @@ -0,0 +1,8 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + } + } +} diff --git a/appsettings.json b/appsettings.json new file mode 100644 index 0000000..10f68b8 --- /dev/null +++ b/appsettings.json @@ -0,0 +1,9 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + } + }, + "AllowedHosts": "*" +}