initial commit

This commit is contained in:
Renjaya Raga Zenta 2025-07-27 16:02:56 +07:00
commit c92383721d
16 changed files with 786 additions and 0 deletions

View file

@ -0,0 +1,18 @@
<Project Sdk="Microsoft.NET.Sdk.Web">
<PropertyGroup>
<TargetFramework>net8.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<InvariantGlobalization>true</InvariantGlobalization>
<PublishAot>true</PublishAot>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="NetVips" Version="3.1.0" />
<PackageReference Include="NetVips.Native.linux-x64" Version="8.17.1" />
<PackageReference Include="Validation" Version="2.6.68" />
<PackageReference Include="ZLogger" Version="2.5.10" />
</ItemGroup>
</Project>

View file

@ -0,0 +1,55 @@
using System.IO.Pipelines;
using Microsoft.AspNetCore.Http.Json;
using NetVips;
using Oh.My.Stitcher;
using Validation;
using ZLogger;
WebApplicationBuilder builder = WebApplication.CreateSlimBuilder(args);
builder.Logging.ClearProviders().AddZLoggerConsole();
builder.Services.Configure<JsonOptions>(options =>
{
options.SerializerOptions.TypeInfoResolver = StitchSerializerContext.Default;
});
WebApplication app = builder.Build();
ILoggerFactory loggerFactory = app.Services.GetRequiredService<ILoggerFactory>();
ILogger logger = loggerFactory.CreateLogger<Program>();
string? tilesDirectory = Environment.GetEnvironmentVariable("ASSET_PATH_RO");
// sanity check
Assumes.NotNullOrEmpty(tilesDirectory);
Assumes.True(File.Exists(Path.Combine(tilesDirectory, "A1.png")));
Assumes.True(File.Exists(Path.Combine(tilesDirectory, "AE55.png")));
app.UseDefaultFiles();
app.UseStaticFiles();
app.MapPost("/api/image/generate", (Stitch request) =>
{
Pipe pipe = new();
_ = Task.Run(async () =>
{
List<Image> images = [];
try
{
using Image image = Tile.Create(in request, tilesDirectory, images, logger);
image.WriteToStream(pipe.Writer.AsStream(), ".png");
}
catch( Exception e )
{
logger.ZLogError(e, $"Error when generating image");
using Image errorImage = Tile.CreateError(e);
errorImage.WriteToStream(pipe.Writer.AsStream(), ".png");
}
finally
{
foreach( Image img in images )
img.Dispose();
await pipe.Writer.CompleteAsync();
}
});
return Results.Stream(pipe.Reader.AsStream(), "image/png");
});
app.Run();

View file

@ -0,0 +1,15 @@
{
"$schema": "http://json.schemastore.org/launchsettings.json",
"profiles": {
"http": {
"commandName": "Project",
"dotnetRunMessages": true,
"launchBrowser": true,
"applicationUrl": "http://localhost:5108",
"environmentVariables": {
"ASPNETCORE_ENVIRONMENT": "Development",
"ASSET_PATH_RO": "/home/formulatrix/Downloads/tiles1705"
}
}
}
}

View file

@ -0,0 +1,67 @@
using System.Text.Json;
using System.Text.Json.Serialization;
namespace Oh.My.Stitcher;
public readonly record struct CropOffset(float X, float Y);
public readonly record struct CropSize(float Width, float Height);
public readonly record struct Stitch(
[property: JsonPropertyName("canvas_rect")]
string CanvasRect,
[property: JsonPropertyName("crop_offset"), JsonConverter(typeof(CropOffsetConverter))]
CropOffset CropOffset,
[property: JsonPropertyName("crop_size"), JsonConverter(typeof(CropSizeConverter))]
CropSize CropSize,
[property: JsonPropertyName("output_scale")]
float OutputScale
);
[JsonSerializable(typeof(Stitch))]
internal partial class StitchSerializerContext : JsonSerializerContext;
public class CropOffsetConverter : JsonConverter<CropOffset>
{
public override CropOffset Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options)
{
if( reader.TokenType != JsonTokenType.StartArray ) throw new JsonException();
reader.Read();
float x = reader.GetSingle();
reader.Read();
float y = reader.GetSingle();
reader.Read();
if( reader.TokenType != JsonTokenType.EndArray ) throw new JsonException();
return new CropOffset(x, y);
}
public override void Write(Utf8JsonWriter writer, CropOffset value, JsonSerializerOptions options)
{
writer.WriteStartArray();
writer.WriteNumberValue(value.X);
writer.WriteNumberValue(value.Y);
writer.WriteEndArray();
}
}
public class CropSizeConverter : JsonConverter<CropSize>
{
public override CropSize Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options)
{
if( reader.TokenType != JsonTokenType.StartArray ) throw new JsonException();
reader.Read();
float width = reader.GetSingle();
reader.Read();
float height = reader.GetSingle();
reader.Read();
if( reader.TokenType != JsonTokenType.EndArray ) throw new JsonException();
return new CropSize(width, height);
}
public override void Write(Utf8JsonWriter writer, CropSize value, JsonSerializerOptions options)
{
writer.WriteStartArray();
writer.WriteNumberValue(value.Width);
writer.WriteNumberValue(value.Height);
writer.WriteEndArray();
}
}

105
src/Oh.My.Stitcher/Tile.cs Normal file
View file

@ -0,0 +1,105 @@
// ReSharper disable ReplaceSliceWithRangeIndexer
using NetVips;
using ZLogger;
namespace Oh.My.Stitcher;
public static class Tile
{
public static Image Create(in Stitch request, string tilesDirectory, List<Image> images, ILogger logger)
{
if( !TryParseRect(request.CanvasRect, out int minRow, out int maxRow, out int minCol, out int maxCol) )
throw new ArgumentException($"Invalid canvas_rect: '{request.CanvasRect}'");
logger.ZLogDebug(
$"rect: {request.CanvasRect}, minRow: {minRow}, maxRow: {maxRow}, minCol: {minCol}, maxCol: {maxCol}");
int width = maxCol - minCol + 1;
for( int row = minRow; row <= maxRow; row++ )
for( int col = minCol; col <= maxCol; col++ )
images.Add(Image.NewFromFile(FullPath(tilesDirectory, row, col)));
using var canvasImage = Image.Arrayjoin(images.ToArray(), width);
int cropLeft = (int)( canvasImage.Width * request.CropOffset.X );
int cropTop = (int)( canvasImage.Height * request.CropOffset.Y );
int cropWidth = (int)( canvasImage.Width * request.CropSize.Width );
int cropHeight = (int)( canvasImage.Height * request.CropSize.Height );
return canvasImage.Crop(cropLeft, cropTop, cropWidth, cropHeight).Resize(request.OutputScale);
}
public static Image CreateError(Exception e)
{
const int padding = 20;
using var text = Image.Text($"Error:\n{e.Message}", dpi: 96, align: Enums.Align.Low);
return text.Embed(padding, padding, text.Width + ( 2 * padding ), text.Height + ( 2 * padding ));
}
private static string FullPath(string directory, int row, int col)
{
string letterPart = "";
while (row > 0)
{
int remainder = (row - 1) % 26;
letterPart = (char)('A' + remainder) + letterPart;
row = (row - 1) / 26;
}
string fileName = $"{letterPart}{col}.png";
return Path.Combine(directory, fileName);
}
private static bool TryParseRect(string rect, out int minRow, out int maxRow, out int minCol, out int maxCol)
{
minRow = maxRow = minCol = maxCol = 0;
string[] corners = rect.Split(':');
switch( corners.Length )
{
case 1:
{
if( !TryParseName(corners[0], out int r, out int c) )
return false;
minRow = maxRow = r;
minCol = maxCol = c;
return true;
}
case 2:
{
if( !TryParseName(corners[0], out int r1, out int c1) || !TryParseName(corners[1], out int r2, out int c2) )
return false;
minRow = Math.Min(r1, r2);
maxRow = Math.Max(r1, r2);
minCol = Math.Min(c1, c2);
maxCol = Math.Max(c1, c2);
return true;
}
default:
return false;
}
}
private static bool TryParseName(string name, out int row, out int col)
{
row = col = 0;
ReadOnlySpan<char> span = name.AsSpan().Trim();
int splitIndex = span.IndexOfAnyInRange('0', '9');
if( splitIndex <= 0 )
return false;
if( !int.TryParse(span.Slice(splitIndex), out col))
return false;
int letter = 0;
foreach( char c in span.Slice(0, splitIndex) )
{
char upper = char.ToUpperInvariant(c);
if( upper is < 'A' or > 'Z' )
return false;
letter = letter * 26 + ( upper - 'A' + 1 );
}
row = letter;
return true;
}
}

View file

@ -0,0 +1,8 @@
{
"Logging": {
"LogLevel": {
"Default": "Debug",
"Microsoft.AspNetCore": "Warning"
}
}
}

View file

@ -0,0 +1,9 @@
{
"Logging": {
"LogLevel": {
"Default": "Information",
"Microsoft.AspNetCore": "Warning"
}
},
"AllowedHosts": "*"
}

View file

@ -0,0 +1,214 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Oh My Stitcher!</title>
<style>
body {
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
background-color: #f4f7f9;
color: #333;
display: flex;
flex-direction: column;
align-items: center;
margin: 0;
padding: 2rem;
}
.container {
background: white;
padding: 2rem;
border-radius: 8px;
box-shadow: 0 4px 15px rgba(0, 0, 0, 0.1);
width: 100%;
max-width: 500px;
}
h1 {
text-align: center;
color: #2c3e50;
}
form {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 1rem 1.5rem;
}
.full-width {
grid-column: 1 / -1;
}
label {
font-weight: 600;
margin-bottom: 0.25rem;
display: block;
}
input[type="text"], input[type="number"] {
width: 100%;
padding: 0.75rem;
border: 1px solid #dcdcdc;
border-radius: 4px;
box-sizing: border-box;
font-size: 1rem;
}
button {
grid-column: 1 / -1;
padding: 1rem;
font-size: 1.1rem;
font-weight: 600;
color: white;
background-color: #3498db;
border: none;
border-radius: 4px;
cursor: pointer;
transition: background-color 0.2s;
}
button:hover {
background-color: #2980b9;
}
button:disabled {
background-color: #95a5a6;
cursor: not-allowed;
}
#output-container {
margin-top: 1.5rem;
text-align: center;
width: 100%;
}
#output-container img {
max-width: 100%;
max-height: 80vh;
height: auto;
width: auto;
border: 1px solid #dcdcdc;
border-radius: 4px;
background-color: #f8f9fa;
box-shadow: 0 2px 5px rgba(0,0,0,0.05);
}
#status {
margin-top: 1rem;
width: 100%;
max-width: 500px;
box-sizing: border-box;
text-align: center;
font-weight: 500;
padding: 0.75rem;
}
.status-error {
color: #c0392b;
background-color: #f9e2e2;
}
.status-info {
color: #2980b9;
}
.status-success {
color: #27ae60;
background-color: #eaf7f0;
}
</style>
</head>
<body>
<div class="container">
<h1>Oh My Stitcher!</h1>
<form id="generator-form">
<div class="full-width">
<label for="canvasRect">Canvas Rect (e.g., A1:H12 or B5)</label>
<input type="text" id="canvasRect" value="A1:H12" required>
</div>
<div>
<label for="cropOffsetX">Crop Offset X</label>
<input type="number" id="cropOffsetX" value="0.25" step="0.01" min="0" max="1">
</div>
<div>
<label for="cropOffsetY">Crop Offset Y</label>
<input type="number" id="cropOffsetY" value="0.25" step="0.01" min="0" max="1">
</div>
<div>
<label for="cropSizeWidth">Crop Size Width</label>
<input type="number" id="cropSizeWidth" value="0.5" step="0.01" min="0" max="1">
</div>
<div>
<label for="cropSizeHeight">Crop Size Height</label>
<input type="number" id="cropSizeHeight" value="0.5" step="0.01" min="0" max="1">
</div>
<div class="full-width">
<label for="outputScale">Output Scale</label>
<input type="number" id="outputScale" value="0.05" step="0.01" min="0.01" max="0.05">
</div>
<button type="submit" id="generate-btn">Generate Image</button>
</form>
</div>
<div id="status"></div>
<div id="output-container"></div>
<script>
const form = document.getElementById('generator-form');
const generateBtn = document.getElementById('generate-btn');
const statusDiv = document.getElementById('status');
const outputContainer = document.getElementById('output-container');
form.addEventListener('submit', async (event) => {
event.preventDefault();
generateBtn.disabled = true;
generateBtn.textContent = 'Generating...';
statusDiv.textContent = 'Sending request...';
statusDiv.className = 'status-info';
const existingImg = outputContainer.querySelector('img');
if (existingImg) existingImg.remove();
const payload = {
canvas_rect: document.getElementById('canvasRect').value,
crop_offset: [
parseFloat(document.getElementById('cropOffsetX').value),
parseFloat(document.getElementById('cropOffsetY').value)
],
crop_size: [
parseFloat(document.getElementById('cropSizeWidth').value),
parseFloat(document.getElementById('cropSizeHeight').value)
],
output_scale: parseFloat(document.getElementById('outputScale').value)
};
const startTime = performance.now();
try {
const response = await fetch('/api/image/generate', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload)
});
const headersReceivedTime = performance.now();
const timeToFirstByte = (headersReceivedTime - startTime).toFixed(1);
if (!response.ok) {
const errorData = await response.json();
throw new Error(`Server error (${response.status}): ${errorData.title || 'Unknown Error'}`);
}
statusDiv.textContent = `Headers received in ${timeToFirstByte} ms. Downloading image...`;
const imageBlob = await response.blob();
const endTime = performance.now();
const totalDuration = (endTime - startTime).toFixed(1);
const imageUrl = URL.createObjectURL(imageBlob);
const img = document.createElement('img');
img.src = imageUrl;
img.onload = () => URL.revokeObjectURL(img.src);
outputContainer.appendChild(img);
const sizeInMB = (imageBlob.size / 1024 / 1024).toFixed(2);
statusDiv.textContent = `Success! Image (${sizeInMB} MB) received in ${totalDuration} ms.`;
statusDiv.className = 'status-success';
} catch (error) {
console.error('Fetch error:', error);
statusDiv.textContent = error.message;
statusDiv.className = 'status-error';
} finally {
generateBtn.disabled = false;
generateBtn.textContent = 'Generate Image';
}
});
</script>
</body>
</html>