First Commit

This commit is contained in:
reinard.setiadji@formulatrix.com 2025-08-01 15:29:06 +07:00
parent bb40883c7d
commit 696158848f
18 changed files with 787 additions and 0 deletions

98
StitchATon/Controller.cs Normal file
View file

@ -0,0 +1,98 @@
using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.ObjectPool;
using OpenCvSharp;
using StitchATon.DTO;
using StitchATon.Services;
using StitchATon.Utility;
namespace StitchATon;
[ApiController]
[Route("api/image/")]
public class ImageController( ImageProvider ip, ObjectPool<PngNamedPipe> npPool, ILogger<Controller> logger ) : ControllerBase
{
[HttpPost]
[Route("generate")]
public async Task<IActionResult> GetImage([FromBody] GenerateInput generateInput)
{
logger.LogInformation( $"GetImage requested at {generateInput.CanvasRect}" );
var namedPipe = npPool.Get();
var imagePath = namedPipe.PipeFullname;
using var roiIm = await ip.GetImage(
generateInput.ParsedCanvasRect(),
generateInput.ParsedCropOffset(),
generateInput.ParsedCropSize()
);
var scaledSize = new Size(
roiIm.Cols * generateInput.OutputScale,
roiIm.Rows * generateInput.OutputScale
);
Mat resizedIm = new();
if( scaledSize == roiIm.Size() )
resizedIm = roiIm;
else
Cv2.Resize( roiIm, resizedIm, scaledSize );
// Spawn new task to write to the named pipe
Task.Run( () =>
{
Cv2.ImWrite( imagePath, resizedIm );
resizedIm.Dispose();
} );
// Stream the named pipe as output
var fileStream = new FileStream(
imagePath,
FileMode.Open,
FileAccess.Read,
FileShare.Read,
4096,
FileOptions.Asynchronous | FileOptions.SequentialScan);
return File(fileStream, "image/png");
}
[HttpGet]
[Route("sanity")]
public async Task<IActionResult> GetImageSanityTest()
{
var namedPipe = npPool.Get();
var imagePath = namedPipe.PipeFullname;
using var roiIm = await ip.GetImage(
new Rect( new Point( 5, 6 ), new Size( 3, 3 ) ),
new Point2f( .1f, .1f ),
new Point2f( .8f, .8f ) );
var mul = .7;
var scaledSize = new Size(roiIm.Cols * mul , roiIm.Rows * mul);
Mat resizedIm = new();
if( scaledSize == roiIm.Size() )
resizedIm = roiIm;
else
Cv2.Resize( roiIm, resizedIm, scaledSize );
// Spawn new task to write to the named pipe
Task.Run( () =>
{
Cv2.ImWrite( imagePath, resizedIm );
resizedIm.Dispose();
} );
// Stream the named pipe as output
var fileStream = new FileStream(
imagePath,
FileMode.Open,
FileAccess.Read,
FileShare.Read,
4096,
FileOptions.Asynchronous | FileOptions.SequentialScan);
return File(fileStream, "image/png");
}
}

View file

@ -0,0 +1,48 @@
using JetBrains.Annotations;
using OpenCvSharp;
namespace StitchATon.DTO;
public class GenerateInput
{
public required string CanvasRect { get; [UsedImplicitly] init; }
public required float[] CropOffset { get; [UsedImplicitly] init; }
public required float[] CropSize { get; [UsedImplicitly] init; }
public required float OutputScale { get; [UsedImplicitly] init; }
public Rect ParsedCanvasRect()
{
var corners = CanvasRect.Split( ":" );
var c1 = ParseCanvasCoord( corners[0] );
var c2 = ParseCanvasCoord( corners[1] );
return new Rect(
int.Min( c1.X, c2.X ),
int.Min( c1.Y, c2.Y ),
int.Abs( c1.X - c2.X ) + 1, // Inclusive bbox
int.Abs( c1.Y - c2.Y ) + 1 // Inclusive bbox
);
}
private Point ParseCanvasCoord(string labwareCoord)
{
int y = 0;
int x = 0;
foreach( var c in labwareCoord )
{
if( 'A' <= c && c <= 'Z' )
y = ( y * 26 ) + ( c - 'A' + 1);
else
x = ( x * 10 ) + ( c - '0' );
}
y--;
x--;
return new(x, y);
}
public Point2f ParsedCropOffset() => new( CropOffset[0], CropOffset[1] );
public Point2f ParsedCropSize() => new( CropSize[0], CropSize[1] );
}

50
StitchATon/Program.cs Normal file
View file

@ -0,0 +1,50 @@
using System.Text.Json;
using Microsoft.Extensions.ObjectPool;
using StitchATon.Services;
using StitchATon.Utility;
var builder = WebApplication.CreateBuilder( args );
builder.Services.AddEndpointsApiExplorer();
builder.Services.AddSwaggerGen();
var path = Environment.GetEnvironmentVariable( "ASSET_PATH_RO" );
if( path == null )
throw new ArgumentException("Please supply the directory path on ASSET_PATH_RO env var");
// Image Loader
builder.Services.AddSingleton( new ImageProvider.Config(
path,
720,
55,
31) );
builder.Services.AddSingleton<ImageProvider>();
// FIFO named pipe pool
builder.Services.AddSingleton<ObjectPool<PngNamedPipe>>( new DefaultObjectPool<PngNamedPipe>(
new DefaultPooledObjectPolicy<PngNamedPipe>(),
10 ) );
builder.Services.AddControllers().AddJsonOptions( options =>
{
options.JsonSerializerOptions.PropertyNamingPolicy = JsonNamingPolicy.SnakeCaseLower;
options.JsonSerializerOptions.WriteIndented = true;
}
);
builder.Logging.AddConsole();
var app = builder.Build();
// Configure the HTTP request pipeline.
if( app.Environment.IsDevelopment() )
{
app.UseSwagger();
app.UseSwaggerUI();
}
app.UseCors();
app.UseRouting();
app.MapControllers();
app.Run();

View file

@ -0,0 +1,51 @@
{
"$schema": "http://json.schemastore.org/launchsettings.json",
"iisSettings": {
"windowsAuthentication": false,
"anonymousAuthentication": true,
"iisExpress": {
"applicationUrl": "http://localhost:24734",
"sslPort": 44313
}
},
"profiles": {
"deploy": {
"commandName": "Project",
"dotnetRunMessages": true,
"launchBrowser": true,
"launchUrl": "swagger",
"applicationUrl": "http://localhost:5255"
},
"http": {
"commandName": "Project",
"dotnetRunMessages": true,
"launchBrowser": true,
"launchUrl": "swagger",
"applicationUrl": "http://localhost:5255",
"environmentVariables": {
"ASPNETCORE_ENVIRONMENT": "Development",
"ASSET_PATH_RO": "/home/retorikal/Downloads/tiles1705/"
}
},
"https": {
"commandName": "Project",
"dotnetRunMessages": true,
"launchBrowser": true,
"launchUrl": "swagger",
"applicationUrl": "https://localhost:7105;http://localhost:5255",
"environmentVariables": {
"ASPNETCORE_ENVIRONMENT": "Development",
"ASSET_PATH_RO": "/home/retorikal/Downloads/tiles1705/"
}
},
"IIS Express": {
"commandName": "IISExpress",
"launchBrowser": true,
"launchUrl": "swagger",
"environmentVariables": {
"ASPNETCORE_ENVIRONMENT": "Development",
"ASSET_PATH_RO": "/home/retorikal/Downloads/tiles1705/"
}
}
}
}

View file

@ -0,0 +1,172 @@
using OpenCvSharp;
using StitchATon.Utility;
namespace StitchATon.Services;
public class ImageProvider
{
// Terminology
// Chunk: 720*720 region thingy
// Sector: Area defined in image chunk coordinates
// Region: Area defined in image pixel coordinates
public class Config(string imagePath, int sectorDim, int w, int h)
{
public string ImagePath = imagePath;
public int SectorDim = sectorDim;
public int W = w;
public int H = h;
}
private Grid2D<ImageStatus> _ready;
private Mat _canvas;
private readonly Config _config;
private ILogger<ImageProvider> _logger;
enum ImageStatus
{
Blank,
Loading,
Ready
}
public ImageProvider( Config config, ILogger<ImageProvider> logger )
{
_config = config;
_logger = logger;
_ready = new Grid2D<ImageStatus>( _config.W, _config.H );
_canvas = new Mat(
_config.H * _config.SectorDim,
_config.W * _config.SectorDim,
MatType.CV_8UC3
);
}
string GetImagePath( int x, int y )
{
x++;
y++;
string letter = string.Empty;
while (y > 0)
{
y--; // Adjust to make A=0, B=1, ..., Z=25 for modulo operation
int remainder = y % 26;
char digit = (char)('A' + remainder);
letter = digit + letter;
y /= 26;
}
var filename = $"{letter}{x}.png";
return Path.Join( _config.ImagePath, filename );
}
async Task LoadImage( int x, int y )
{
_ready[x, y] = ImageStatus.Loading;
string path = GetImagePath( x, y );
_logger.LogInformation( $"{path} not loaded yet, reading" );
using Mat image = await Task.Run( () => Cv2.ImRead( path ) );
image.CopyTo( GetChunkMat(x, y) );
_ready[x, y] = ImageStatus.Ready;
}
// After this function is run, it is guaranteed that all images concerned within the SoI is loaded to the grand canvas.
// Has a flagging mechanism to just wait if another call of this function is currently loading it.
async Task LoadImages(Rect soi)
{
_logger.LogInformation( $"{soi.Width * soi.Height} chunks required" );
List<Task>? loadTasks = null;
List<(int x, int y)>? loadedByOthers = null;
for( int x = soi.Left; x < soi.Right; x++ )
for( int y = soi.Top; y < soi.Bottom; y++ )
switch( _ready[x, y] )
{
case ImageStatus.Blank:
if( loadTasks == null )
loadTasks = new List<Task>( soi.Width * soi.Height );
loadTasks.Add( LoadImage( x, y ) );
break;
case ImageStatus.Loading:
if( loadedByOthers == null )
loadedByOthers = new List<(int, int)>( 5 );
loadedByOthers.Add( (x, y) );
break;
}
if( loadTasks != null )
{
await Task.WhenAll( loadTasks );
_logger.LogInformation( $"Finished loading {loadTasks.Count} images" );
}
// Spinlock until all images are loaded. 1ms delay to prevent processor overload
while( loadedByOthers != null && loadedByOthers.Count != 0 )
{
await Task.Delay( 1 );
loadedByOthers.RemoveAll( coord => _ready[coord.x, coord.y] == ImageStatus.Ready );
}
}
Mat GetChunkMat( int x, int y )
{
var roi = new Rect(
_config.SectorDim * x,
_config.SectorDim * y,
_config.SectorDim,
_config.SectorDim
);
return _canvas[roi];
}
Rect GetGlobalRoi( Rect soi, Point2f roiOffsetRatio, Point2f roiSizeRatio )
{
var soiSizePx = new Size(
soi.Size.Width * _config.SectorDim,
soi.Size.Height * _config.SectorDim );
return new Rect(
( soi.X * _config.SectorDim ) + (int)( soiSizePx.Width * roiOffsetRatio.X ),
( soi.Y * _config.SectorDim ) + (int)( soiSizePx.Height * roiOffsetRatio.Y ),
(int)( soiSizePx.Width * roiSizeRatio.X ),
(int)( soiSizePx.Height * roiSizeRatio.Y )
);
}
Rect GetSoi( Rect roi )
{
var tl = roi.TopLeft;
var br = roi.BottomRight;
var soiTl = new Point(
tl.X / _config.SectorDim,
tl.Y / _config.SectorDim
);
var soiBr = new Point(
(int) Math.Ceiling( br.X / (float) _config.SectorDim ),
(int) Math.Ceiling( br.Y / (float) _config.SectorDim )
);
return new Rect(
soiTl.X,
soiTl.Y,
soiBr.X - soiTl.X,
soiBr.Y - soiTl.Y );
}
public async Task<Mat> GetImage( Rect soi, Point2f roiOffsetRatio, Point2f roiSizeRatio )
{
var globalRoi = GetGlobalRoi( soi, roiOffsetRatio, roiSizeRatio);
var adaptedSoi = GetSoi( globalRoi );
await LoadImages( adaptedSoi );
return _canvas[globalRoi];
}
}

View file

@ -0,0 +1,22 @@
<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.18"/>
<PackageReference Include="OpenCvSharp4" Version="4.11.0.20250507" />
<PackageReference Include="OpenCvSharp4_runtime.debian.12-x64" Version="4.10.0" />
<PackageReference Include="Swashbuckle.AspNetCore" Version="6.6.2"/>
</ItemGroup>
<ItemGroup>
<Reference Include="JetBrains.Annotations">
<HintPath>..\..\..\.nuget\packages\jetbrains.annotations\2023.3.0\lib\netstandard2.0\JetBrains.Annotations.dll</HintPath>
</Reference>
</ItemGroup>
</Project>

View file

@ -0,0 +1,26 @@
namespace StitchATon.Utility;
public class Grid2D<T>
{
private T[] _buffer;
public readonly int W;
public readonly int H;
public Grid2D( int width, int height )
{
W = width;
H = height;
_buffer = new T[W * H];
}
private int Map( int x, int y )
{
return x + ( y * W );
}
public T this[ int x, int y ]
{
get => _buffer[Map( x, y )];
set => _buffer[Map( x, y )] = value;
}
}

View file

@ -0,0 +1,22 @@
using System.Diagnostics;
namespace StitchATon.Utility;
public class PngNamedPipe : IDisposable
{
private ProcessStartInfo _mkfifoPs = new("mkfifo");
private ProcessStartInfo _rmfifoPs = new("rm");
public readonly string PipeFullname = Path.Join( Path.GetTempPath(), Guid.NewGuid() + ".png" );
public PngNamedPipe( )
{
_mkfifoPs.Arguments = PipeFullname;
Process.Start( _mkfifoPs )?.WaitForExit();
}
public void Dispose()
{
_rmfifoPs.Arguments = PipeFullname;
Process.Start( _rmfifoPs );
}
}

View file

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

View file

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