first commit
This commit is contained in:
commit
b344b6a03f
16 changed files with 405 additions and 0 deletions
13
.config/dotnet-tools.json
Normal file
13
.config/dotnet-tools.json
Normal file
|
|
@ -0,0 +1,13 @@
|
||||||
|
{
|
||||||
|
"version": 1,
|
||||||
|
"isRoot": true,
|
||||||
|
"tools": {
|
||||||
|
"csharpier": {
|
||||||
|
"version": "1.0.3",
|
||||||
|
"commands": [
|
||||||
|
"csharpier"
|
||||||
|
],
|
||||||
|
"rollForward": false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
2
.gitignore
vendored
Normal file
2
.gitignore
vendored
Normal file
|
|
@ -0,0 +1,2 @@
|
||||||
|
/bin/
|
||||||
|
/obj/
|
||||||
50
Controllers/ImageController.cs
Normal file
50
Controllers/ImageController.cs
Normal file
|
|
@ -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
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
11
Models/GenerateImageRequest.cs
Normal file
11
Models/GenerateImageRequest.cs
Normal file
|
|
@ -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
|
||||||
|
);
|
||||||
|
}
|
||||||
21
Program.cs
Normal file
21
Program.cs
Normal file
|
|
@ -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<IImageService, ImageService>();
|
||||||
|
builder.Services.AddProblemDetails();
|
||||||
|
|
||||||
|
var app = builder.Build();
|
||||||
|
|
||||||
|
app.UseExceptionHandler();
|
||||||
|
|
||||||
|
app.MapImageEndpoints();
|
||||||
|
|
||||||
|
app.Run();
|
||||||
24
Properties/launchSettings.json
Normal file
24
Properties/launchSettings.json
Normal file
|
|
@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
8
Services/IImageService.cs
Normal file
8
Services/IImageService.cs
Normal 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
61
Services/ImageService.cs
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
52
Services/Utilities/CoordinateParser.cs
Normal file
52
Services/Utilities/CoordinateParser.cs
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
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 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
|
||||||
|
);
|
||||||
11
Services/Utilities/TileHelper.cs
Normal file
11
Services/Utilities/TileHelper.cs
Normal 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";
|
||||||
|
}
|
||||||
|
}
|
||||||
11
StitcherApi.csproj
Normal file
11
StitcherApi.csproj
Normal file
|
|
@ -0,0 +1,11 @@
|
||||||
|
<Project Sdk="Microsoft.NET.Sdk.Web">
|
||||||
|
<PropertyGroup>
|
||||||
|
<TargetFramework>net8.0</TargetFramework>
|
||||||
|
<Nullable>enable</Nullable>
|
||||||
|
<ImplicitUsings>enable</ImplicitUsings>
|
||||||
|
</PropertyGroup>
|
||||||
|
<ItemGroup>
|
||||||
|
<PackageReference Include="Microsoft.AspNetCore.OpenApi" Version="8.0.5" />
|
||||||
|
<PackageReference Include="SixLabors.ImageSharp" Version="3.1.10" />
|
||||||
|
</ItemGroup>
|
||||||
|
</Project>
|
||||||
6
StitcherApi.http
Normal file
6
StitcherApi.http
Normal file
|
|
@ -0,0 +1,6 @@
|
||||||
|
@StitcherApi_HostAddress = http://localhost:5229
|
||||||
|
|
||||||
|
GET {{StitcherApi_HostAddress}}/weatherforecast/
|
||||||
|
Accept: application/json
|
||||||
|
|
||||||
|
###
|
||||||
24
StitcherApi.sln
Normal file
24
StitcherApi.sln
Normal file
|
|
@ -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
|
||||||
8
appsettings.Development.json
Normal file
8
appsettings.Development.json
Normal file
|
|
@ -0,0 +1,8 @@
|
||||||
|
{
|
||||||
|
"Logging": {
|
||||||
|
"LogLevel": {
|
||||||
|
"Default": "Information",
|
||||||
|
"Microsoft.AspNetCore": "Warning"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
9
appsettings.json
Normal file
9
appsettings.json
Normal file
|
|
@ -0,0 +1,9 @@
|
||||||
|
{
|
||||||
|
"Logging": {
|
||||||
|
"LogLevel": {
|
||||||
|
"Default": "Information",
|
||||||
|
"Microsoft.AspNetCore": "Warning"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"AllowedHosts": "*"
|
||||||
|
}
|
||||||
Loading…
Add table
Add a link
Reference in a new issue