diff --git a/App/Controllers/ImageController.cs b/App/Controllers/ImageController.cs index e04cfb0..e723baf 100644 --- a/App/Controllers/ImageController.cs +++ b/App/Controllers/ImageController.cs @@ -25,6 +25,8 @@ public static class ImageController response.StatusCode = 200; response.ContentType = "image/png"; + + Console.WriteLine($"Generate image for {dto}"); await tileManager .CreateSection(dto) diff --git a/App/Models/GenerateImageDto.cs b/App/Models/GenerateImageDto.cs index 85a726d..1d77d3a 100644 --- a/App/Models/GenerateImageDto.cs +++ b/App/Models/GenerateImageDto.cs @@ -69,4 +69,9 @@ public class GenerateImageDto yield return (fieldName, $"{fieldName} must be less than or equal to {max}."); } } + + public override string ToString() + { + return $"CoordinatePair: {CanvasRect}, Crop: [{CropOffset![0]} {CropOffset[1]} {CropSize![0]} {CropSize[1]}], OutputScale: {OutputScale}"; + } } \ No newline at end of file diff --git a/App/Program.cs b/App/Program.cs index 8bec90f..04c5523 100644 --- a/App/Program.cs +++ b/App/Program.cs @@ -5,7 +5,7 @@ using StitchATon2.Domain; var builder = WebApplication.CreateSlimBuilder(args); -using var tileManager = new TileManager(Configuration.Default); +var tileManager = new TileManager(Configuration.Default); builder.Services.AddSingleton(tileManager); builder.Services.ConfigureHttpJsonOptions(options => diff --git a/Domain/GridSection.cs b/Domain/GridSection.cs index 72d1a59..3b9a9ea 100644 --- a/Domain/GridSection.cs +++ b/Domain/GridSection.cs @@ -47,5 +47,10 @@ public class GridSection (var rowOffset, OffsetY) = Math.DivRem(y0, config.Height); Origin = tileManager.GetTile(col0 + columnOffset, row0 + rowOffset); + Console.Write($"Origin: {Origin.Coordinate} ({Origin.Column}, {Origin.Row}) "); + Console.Write($"Tile offset: [{columnOffset} {rowOffset}] "); + Console.Write($"Pixel offset: [{OffsetX} {OffsetY}] "); + Console.Write($"Size: [{Width}x{Height}]"); + Console.WriteLine(); } } \ No newline at end of file diff --git a/Domain/ImageCreators/DangerousImageCreator.cs b/Domain/ImageCreators/DangerousImageCreator.cs index ebf6441..04453b5 100644 --- a/Domain/ImageCreators/DangerousImageCreator.cs +++ b/Domain/ImageCreators/DangerousImageCreator.cs @@ -39,25 +39,35 @@ public sealed class DangerousImageCreator : IDisposable ~DangerousImageCreator() => Dispose(); - public async Task WriteToPipe(PipeWriter outputPipe, float scale, CancellationToken cancellationToken = default) + public async Task WriteToPipe2(PipeWriter outputPipe, float scale, CancellationToken cancellationToken = default) { var scaleFactor = MathF.ReciprocalEstimate(scale); var targetWidth = (int)(Width / scaleFactor); var targetHeight = (int)(Height / scaleFactor); + if (targetHeight == 0 || targetWidth == 0) + return; var encoder = new PngPipeEncoder(outputPipe, targetWidth, targetHeight); encoder.WriteHeader(); var outputBufferSize = targetWidth * Unsafe.SizeOf(); - using var xLookup = Utils.BoundsMatrix(scaleFactor, targetWidth, FullWidth, OffsetX); - using var yLookup = Utils.BoundsMatrix(scaleFactor, targetHeight, FullHeight, OffsetY); + var xLookup = Utils.DoubleBoundsMatrix(scaleFactor, targetWidth, FullWidth, OffsetX); + var yLookup = Utils.DoubleBoundsMatrix(scaleFactor, targetHeight, FullHeight, OffsetY); - using var yStartMap = MemoryAllocator.Allocate(targetWidth); - using var yEndMap = MemoryAllocator.Allocate(targetWidth); + using var xLookup0 = xLookup.Item1; + using var xLookup1 = xLookup.Item2; + using var yLookup0 = yLookup.Item1; + using var yLookup1 = yLookup.Item2; + + using var yStartMap0 = MemoryAllocator.Allocate(targetWidth); + using var yStartMap1 = MemoryAllocator.Allocate(targetWidth); + using var yEndMap0 = MemoryAllocator.Allocate(targetWidth); + using var yEndMap1 = MemoryAllocator.Allocate(targetWidth); - var yStart = OffsetY; - + // var yStart = OffsetY; + + // Use pixel referencing to eliminate type casting var pxInt32 = Int32Pixel.Zero; ref var px = ref pxInt32; ref var rChannel = ref Unsafe.As(ref px); @@ -67,27 +77,151 @@ public sealed class DangerousImageCreator : IDisposable var outputTaskQueue = TaskHelper.SynchronizedTaskFactory.StartNew(() => { }, cancellationToken); for (var y = 0; y < targetHeight; y++) { - var yEnd = yLookup[y]; + var yStart = yLookup0[y]; + var yEnd = yLookup1[y]; var (localRow0, localOffsetY0) = int.DivRem(yStart, TileHeight); - MapRow(localRow0, localOffsetY0, xLookup, targetWidth, yStartMap); + MapRow(localRow0, localOffsetY0, xLookup0, targetWidth, yStartMap0); + MapRow(localRow0, localOffsetY0, xLookup1, targetWidth, yStartMap1); var (localRow1, localOffsetY1) = int.DivRem(yEnd, TileHeight); - MapRow(localRow1, localOffsetY1, xLookup, targetWidth, yEndMap); + MapRow(localRow1, localOffsetY1, xLookup0, targetWidth, yEndMap0); + MapRow(localRow1, localOffsetY1, xLookup1, targetWidth, yEndMap1); if (localRow0 != localRow1) { - MapRow(localRow0, BottomPixelIndex, xLookup, targetWidth, yEndMap, true); + MapRow(localRow0, BottomPixelIndex, xLookup0, targetWidth, yEndMap0, true); + MapRow(localRow0, BottomPixelIndex, xLookup1, targetWidth, yEndMap1, true); } - int xStart = OffsetX, x0 = 0; + // int xStart = OffsetX, x0 = 0; var outputBuffer = MemoryAllocator.Allocate(outputBufferSize); ref var outputChannel = ref outputBuffer.Span[0]; - var boxHeight = yEnd - yStart; - for (int x1 = 0; x1 < targetWidth; x1++) + var boxHeight = Math.Max(1, yEnd - yStart); + for (int x = 0; x < targetWidth; x++) { - var xEnd = xLookup[x1]; + var xStart = xLookup0[x]; + var xEnd = xLookup1[x]; + + px = yEndMap1[x]; + px += yStartMap0[x]; + px -= yEndMap0[x]; + px -= yStartMap1[x]; + px /= Math.Max(1, xEnd - xStart) * boxHeight; + + outputChannel = rChannel; + outputChannel = ref Unsafe.AddByteOffset(ref outputChannel, 1); + + outputChannel = gChannel; + outputChannel = ref Unsafe.AddByteOffset(ref outputChannel, 1); + + outputChannel = bChannel; + outputChannel = ref Unsafe.AddByteOffset(ref outputChannel, 1); + + // xStart = xEnd; + // x0 = x; + } + + outputTaskQueue = outputTaskQueue + .ContinueWith(async _ => + { + await encoder.WriteDataAsync(outputBuffer, cancellationToken: cancellationToken); + }, cancellationToken); + + yStart = yEnd; + } + + await outputTaskQueue; + await encoder.WriteEndOfFileAsync(cancellationToken); + } + + public async Task WriteToPipe(PipeWriter outputPipe, float scale, CancellationToken cancellationToken = default) + { + var scaleFactor = MathF.ReciprocalEstimate(scale); + var targetWidth = (int)(Width / scaleFactor); + var targetHeight = (int)(Height / scaleFactor); + if (targetHeight == 0 || targetWidth == 0) + return; + + var encoder = new PngPipeEncoder(outputPipe, targetWidth, targetHeight); + encoder.WriteHeader(); + + Task outputTaskQueue; + + var outputBufferSize = targetWidth * Unsafe.SizeOf(); + + using var xLookup = Utils.BoundsMatrix(scaleFactor, targetWidth, FullWidth, OffsetX); + using var yLookup = Utils.BoundsMatrix(scaleFactor, targetHeight, FullHeight, OffsetY); + + using var yStartMap = MemoryAllocator.Allocate(targetWidth + 1); + using var yEndMap = MemoryAllocator.Allocate(targetWidth + 1); + // OffsetX-(int)float.Ceiling(scaleFactor) + int yStart = OffsetY, + yEnd = yLookup[0], + xStart = OffsetX, + x0 = 0; + + + + // Use pixel referencing to eliminate type casting + var pxInt32 = Int32Pixel.Zero; + ref var px = ref pxInt32; + ref var rChannel = ref Unsafe.As(ref px); + ref var gChannel = ref Unsafe.Add(ref rChannel, 4); + ref var bChannel = ref Unsafe.Add(ref rChannel, 8); + + // First row + var (localRow0, localOffsetY0) = int.DivRem(yStart, TileHeight); + var (localRow1, localOffsetY1) = int.DivRem(yEnd, TileHeight); + { + switch (localOffsetY0) + { + // Cross row tile, no need to handle if it's first row tile for now + // the provided asset is bordered black anyway + case 0 when TileOrigin.Row > 1: + localOffsetY0 = BottomPixelIndex; + localRow0--; + break; + case > 0: + localOffsetY0--; + break; + } + + MapRow(localRow0, localOffsetY0, xLookup, targetWidth, yStartMap); + MapRow(localRow1, localOffsetY1, xLookup, targetWidth, yEndMap); + + // Cross row + if (localRow0 != localRow1) + MapRow(localRow0, BottomPixelIndex, xLookup, targetWidth, yEndMap, true); + + var outputBuffer = MemoryAllocator.Allocate(outputBufferSize); + ref var outputChannel = ref outputBuffer.Span[0]; + var boxHeight = yEnd - yStart; + + // Render first pixel row + var xEnd = xLookup[0]; + px = yEndMap[0]; + px += yStartMap[^1]; + px -= yEndMap[^1]; + px -= yStartMap[0]; + px /= Math.Max(1, (xEnd - xStart) * boxHeight); + + outputChannel = rChannel; + outputChannel = ref Unsafe.AddByteOffset(ref outputChannel, 1); + + outputChannel = gChannel; + outputChannel = ref Unsafe.AddByteOffset(ref outputChannel, 1); + + outputChannel = bChannel; + outputChannel = ref Unsafe.AddByteOffset(ref outputChannel, 1); + + xStart = xEnd; + + // Render entire pixel row + for (int x1 = 1; x1 < targetWidth; x1++) + { + xEnd = xLookup[x1]; px = yEndMap[x1]; px += yStartMap[x0]; @@ -96,13 +230,83 @@ public sealed class DangerousImageCreator : IDisposable px /= Math.Max(1, (xEnd - xStart) * boxHeight); outputChannel = rChannel; - outputChannel = ref Unsafe.Add(ref outputChannel, 1); + outputChannel = ref Unsafe.AddByteOffset(ref outputChannel, 1); outputChannel = gChannel; - outputChannel = ref Unsafe.Add(ref outputChannel, 1); + outputChannel = ref Unsafe.AddByteOffset(ref outputChannel, 1); outputChannel = bChannel; - outputChannel = ref Unsafe.Add(ref outputChannel, 1); + outputChannel = ref Unsafe.AddByteOffset(ref outputChannel, 1); + + xStart = xEnd; + x0 = x1; + } + + outputTaskQueue = TaskHelper.SynchronizedTaskFactory.StartNew(async _ => + { + await encoder.WriteDataAsync(outputBuffer, cancellationToken: cancellationToken); + }, null, cancellationToken); + + yStart = yEnd; + } + + for (var y = 1; y < targetHeight; y++) + { + yEnd = yLookup[y]; + + (localRow0, localOffsetY0) = int.DivRem(yStart, TileHeight); + MapRow(localRow0, localOffsetY0, xLookup, targetWidth, yStartMap); + + (localRow1, localOffsetY1) = int.DivRem(yEnd, TileHeight); + MapRow(localRow1, localOffsetY1, xLookup, targetWidth, yEndMap); + + // Cross row + if (localRow0 != localRow1) + MapRow(localRow0, BottomPixelIndex, xLookup, targetWidth, yEndMap, true); + + xStart = OffsetX; + x0 = 0; + + var outputBuffer = MemoryAllocator.Allocate(outputBufferSize); + ref var outputChannel = ref outputBuffer.Span[0]; + var boxHeight = yEnd - yStart; + + var xEnd = xLookup[0]; + px = yEndMap[0]; + px += yStartMap[^1]; + px -= yEndMap[^1]; + px -= yStartMap[0]; + px /= Math.Max(1, (xEnd - xStart) * boxHeight); + + outputChannel = rChannel; + outputChannel = ref Unsafe.AddByteOffset(ref outputChannel, 1); + + outputChannel = gChannel; + outputChannel = ref Unsafe.AddByteOffset(ref outputChannel, 1); + + outputChannel = bChannel; + outputChannel = ref Unsafe.AddByteOffset(ref outputChannel, 1); + + xStart = xEnd; + + for (int x1 = 1; x1 < targetWidth; x1++) + { + xEnd = xLookup[x1]; + + px = yEndMap[x1]; + px += yStartMap[x0]; + px -= yEndMap[x0]; + px -= yStartMap[x1]; + px /= Math.Max(1, (xEnd - xStart) * boxHeight); + + outputChannel = rChannel; + outputChannel = ref Unsafe.AddByteOffset(ref outputChannel, 1); + + outputChannel = gChannel; + outputChannel = ref Unsafe.AddByteOffset(ref outputChannel, 1); + + outputChannel = bChannel; + outputChannel = ref Unsafe.AddByteOffset(ref outputChannel, 1); xStart = xEnd; x0 = x1; @@ -136,10 +340,31 @@ public sealed class DangerousImageCreator : IDisposable var written = 0; var destinationSpan = destination.Span; var readBufferSpan = _mmfReadBuffer.Span; + + var negative = sourceMap[0] - 1; + var negativePixel = appendMode ? destinationSpan[^1] : Int32Pixel.Zero; + if (negative >= 0) + { + negativePixel = readBufferSpan[negative]; + } + else if(currentTile.Column > 1) + { + // Cross row tile, no need to handle if it's first column tile for now + // the provided asset is bordered black anyway + TileManager.GetAdjacent(currentTile, -1, 0) + .Integral + .Acquire(yOffset, readBufferSpan); + negativePixel = readBufferSpan[RightmostPixelIndex]; + xAdder = readBufferSpan[RightmostPixelIndex]; + } + + destinationSpan[^1] = negativePixel; + while (true) { currentTile.Integral.Acquire(yOffset, readBufferSpan); int localX; + if (appendMode) { while (written < sourceMap.Length && (localX = sourceMap[written] - xOffset) < TileWidth) diff --git a/Domain/TileManager.cs b/Domain/TileManager.cs index 9bb20b1..0b9864f 100644 --- a/Domain/TileManager.cs +++ b/Domain/TileManager.cs @@ -5,29 +5,26 @@ using StitchATon2.Infra.Buffers; namespace StitchATon2.Domain; -public sealed class TileManager : IDisposable +public sealed class TileManager { - private readonly IMemoryOwner _tiles; + private readonly Tile[] _tiles; public Configuration Configuration { get; } public TileManager(Configuration config) { Configuration = config; - _tiles = MemoryAllocator.AllocateManaged(config.TileCount); - var tilesSpan = _tiles.Memory.Span; + _tiles = new Tile[Configuration.TileCount]; for (var id = 0; id < config.TileCount; id++) - tilesSpan[id] = CreateTile(id); + _tiles[id] = CreateTile(id); Console.WriteLine("Tile manager created"); } - - ~TileManager() => Dispose(); private Tile CreateTile(int id) { var (row, column) = int.DivRem(id, Configuration.Columns); - var coordinate = $"{Utils.GetSBSNotation(++row)}{++column}"; + var coordinate = $"{Utils.GetSBSNotationRow(++row)}{++column}"; return new Tile { Id = id, @@ -47,7 +44,7 @@ public sealed class TileManager : IDisposable private int GetId(int column, int row) => column - 1 + (row - 1) * Configuration.Columns; [MethodImpl(MethodImplOptions.AggressiveInlining)] - public Tile GetTile(int id) => _tiles.Memory.Span[id]; + public Tile GetTile(int id) => _tiles[id]; [MethodImpl(MethodImplOptions.AggressiveInlining)] public Tile GetTile(int column, int row) => GetTile(GetId(column, row)); @@ -99,10 +96,4 @@ public sealed class TileManager : IDisposable cropY, cropWidth, cropHeight); - - public void Dispose() - { - _tiles.Dispose(); - GC.SuppressFinalize(this); - } } \ No newline at end of file diff --git a/Domain/Utils.cs b/Domain/Utils.cs index 4cde46b..63d56c0 100644 --- a/Domain/Utils.cs +++ b/Domain/Utils.cs @@ -9,7 +9,7 @@ namespace StitchATon2.Domain; public static class Utils { [Pure] - public static string GetSBSNotation(int row) + public static string GetSBSNotationRow(int row) => row <= 26 ? new string([(char)(row + 'A' - 1)]) : new string(['A', (char)(row + 'A' - 27)]); @@ -70,7 +70,54 @@ public static class Utils resultSpan[i] = Vector.Add(resultSpan[i], vectorOffset); resultSpan[i] = Vector.ClampNative(resultSpan[i], vectorMin, vectorMax); } + + var negative = float.Ceiling(scaleFactor); return result; } + + public static (IBuffer, IBuffer) DoubleBoundsMatrix(float scaleFactor, int length, int max, int offset) + { + var vectorSize = DivCeil(length, Vector.Count); + using var startBuffer = MemoryAllocator.Allocate>(vectorSize); + using var endBuffer = MemoryAllocator.Allocate>(vectorSize); + + var startSpan = startBuffer.Span; + var endSpan = endBuffer.Span; + + var vectorMin = Vector.Zero; + var vectorOne = Vector.One; + var vectorMax = Vector.Create(max); + var vectorScale = Vector.Create(scaleFactor); + var vectorOffset = new Vector(offset - 1); + + for (int i = 0, seq = 0; i < vectorSize; i++, seq += Vector.Count) + { + startSpan[i] = Vector.CreateSequence(seq, 1f); + startSpan[i] = Vector.Multiply(startSpan[i], vectorScale); + endSpan[i] = Vector.Add(vectorScale, startSpan[i]); + endSpan[i] = Vector.Ceiling(endSpan[i]); + } + + var resultStart = MemoryAllocator.Allocate(vectorSize * Vector.Count); + var resultEnd = MemoryAllocator.Allocate(vectorSize * Vector.Count); + + var resultStartSpan = MemoryMarshal.Cast>(resultStart.Span); + var resultEndSpan = MemoryMarshal.Cast>(resultEnd.Span); + + for (var i = 0; i < vectorSize; i++) + { + resultStartSpan[i] = Vector.ConvertToInt32(startSpan[i]); + resultStartSpan[i] = Vector.Subtract(resultStartSpan[i], vectorOne); + resultStartSpan[i] = Vector.Add(resultStartSpan[i], vectorOffset); + resultStartSpan[i] = Vector.Clamp(resultStartSpan[i], vectorMin, vectorMax); + + resultEndSpan[i] = Vector.ConvertToInt32(endSpan[i]); + resultEndSpan[i] = Vector.Subtract(resultEndSpan[i], vectorOne); + resultEndSpan[i] = Vector.Add(resultEndSpan[i], vectorOffset); + resultEndSpan[i] = Vector.Clamp(resultEndSpan[i], vectorMin, vectorMax); + } + + return (resultStart, resultEnd); + } } \ No newline at end of file diff --git a/Infra/Buffers/PooledMemoryStream.cs b/Infra/Buffers/PooledMemoryStream.cs new file mode 100644 index 0000000..89adb83 --- /dev/null +++ b/Infra/Buffers/PooledMemoryStream.cs @@ -0,0 +1,102 @@ +using System.Buffers; + +namespace StitchATon2.Infra.Buffers; + +public class PooledMemoryStream : Stream +{ + private byte[] _buffer; + private int _length; + private int _position; + private readonly ArrayPool _pool; + private bool _disposed; + + public PooledMemoryStream(int initialCapacity = 1024, ArrayPool? pool = null) + { + _pool = pool ?? ArrayPool.Shared; + _buffer = _pool.Rent(initialCapacity); + } + + public override bool CanRead => !_disposed; + public override bool CanSeek => !_disposed; + public override bool CanWrite => !_disposed; + public override long Length => _length; + public override long Position + { + get => _position; + set + { + if (_disposed) throw new ObjectDisposedException(nameof(PooledMemoryStream)); + if (value < 0 || value > int.MaxValue) throw new ArgumentOutOfRangeException(); + _position = (int)value; + } + } + + public byte[] GetBuffer() => _buffer; + public ArraySegment GetWrittenSegment() => new(_buffer, 0, _length); + + public override void Flush() { /* no-op */ } + + public override int Read(byte[] buffer, int offset, int count) + { + if (_disposed) throw new ObjectDisposedException(nameof(PooledMemoryStream)); + int available = _length - _position; + int toRead = Math.Min(count, available); + Buffer.BlockCopy(_buffer, _position, buffer, offset, toRead); + _position += toRead; + return toRead; + } + + public override void Write(byte[] buffer, int offset, int count) + { + if (_disposed) throw new ObjectDisposedException(nameof(PooledMemoryStream)); + EnsureCapacity(_position + count); + Buffer.BlockCopy(buffer, offset, _buffer, _position, count); + _position += count; + _length = Math.Max(_length, _position); + } + + public override long Seek(long offset, SeekOrigin origin) + { + if (_disposed) throw new ObjectDisposedException(nameof(PooledMemoryStream)); + int newPos = origin switch + { + SeekOrigin.Begin => (int)offset, + SeekOrigin.Current => _position + (int)offset, + SeekOrigin.End => _length + (int)offset, + _ => throw new ArgumentOutOfRangeException() + }; + if (newPos < 0) throw new IOException("Negative position"); + _position = newPos; + return _position; + } + + public override void SetLength(long value) + { + if (_disposed) throw new ObjectDisposedException(nameof(PooledMemoryStream)); + if (value < 0 || value > int.MaxValue) throw new ArgumentOutOfRangeException(); + EnsureCapacity((int)value); + _length = (int)value; + if (_position > _length) _position = _length; + } + + private void EnsureCapacity(int size) + { + if (size <= _buffer.Length) return; + int newSize = Math.Max(size, _buffer.Length * 2); + byte[] newBuffer = _pool.Rent(newSize); + Buffer.BlockCopy(_buffer, 0, newBuffer, 0, _length); + _pool.Return(_buffer, clearArray: true); + _buffer = newBuffer; + } + + protected override void Dispose(bool disposing) + { + if (!_disposed) + { + _pool.Return(_buffer, clearArray: true); + _buffer = Array.Empty(); + _disposed = true; + } + base.Dispose(disposing); + } +} \ No newline at end of file diff --git a/Infra/Encoders/Crc32.cs b/Infra/Encoders/Crc32.cs index e3f106a..32c26d0 100644 --- a/Infra/Encoders/Crc32.cs +++ b/Infra/Encoders/Crc32.cs @@ -15,6 +15,17 @@ public static class Crc32 return ~crc; } + public static uint Compute(Stream stream, int count, uint initial = 0xFFFFFFFF) + { + uint crc = initial; + while (count-- > 0) + { + crc = Table[(crc ^ stream.ReadByte()) & 0xFF] ^ (crc >> 8); + } + + return ~crc; + } + private static uint[] GenerateTable() { const uint poly = 0xEDB88320; diff --git a/Infra/Encoders/PngPipeEncoder.cs b/Infra/Encoders/PngPipeEncoder.cs index 965602d..9a2b844 100644 --- a/Infra/Encoders/PngPipeEncoder.cs +++ b/Infra/Encoders/PngPipeEncoder.cs @@ -71,7 +71,7 @@ public class PngPipeEncoder : IDisposable _zlibStream.Write(buffer.Span.Slice(offset, FlushThreshold)); await _zlibStream.FlushAsync(cancellationToken); offset += FlushThreshold; - if(_outputPipe.UnflushedBytes >= PipeChunkThreshold) + if(_memoryStream.Length >= BufferSize) await FlushAsync(cancellationToken); } diff --git a/Infra/Encoders/UnsafePngEncoder.cs b/Infra/Encoders/UnsafePngEncoder.cs new file mode 100644 index 0000000..87d14e0 --- /dev/null +++ b/Infra/Encoders/UnsafePngEncoder.cs @@ -0,0 +1,166 @@ +using System.Buffers; +using System.Buffers.Binary; +using System.IO.Compression; +using System.IO.Pipelines; +using StitchATon2.Infra.Buffers; + +namespace StitchATon2.Infra.Encoders; + +public class UnsafePngEncoder : IDisposable +{ + private const int BufferSize = 8 * 1024; + private const int FlushThreshold = 1024; + private const int PipeChunkThreshold = 16 * 1024; + + private readonly PipeWriter _outputPipe; + private readonly int _width; + private readonly int _height; + + private MemoryHandle? _memoryHandle; + private readonly RawPointerStream _memoryStream;// = new RawPointerStream(); + private ZLibStream? _zlibStream;// = new ZLibStream(_memoryStream, CompressionLevel.Optimal, leaveOpen: true); + private bool _disposed; + private bool _shouldFlush; + + public UnsafePngEncoder(PipeWriter outputPipe, int width, int height) + { + _outputPipe = outputPipe; + _width = width; + _height = height; + + _memoryStream = new RawPointerStream(); + } + + ~UnsafePngEncoder() => Dispose(); + + public void WriteHeader() + { + Span headerBytes = [ + 0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A, // PNG Signature + 0x00, 0x00, 0x00, 0x0D, // Length + + // IHDR chunk + 0x49, 0x48, 0x44, 0x52, // IHDR + 0x00, 0x00, 0x00, 0x00, // Reserve to write Width + 0x00, 0x00, 0x00, 0x00, // Reserve to write Height + 0x08, // Bit depth + 0x02, // Color type + 0x00, // Compression method + 0x00, // Filter method + 0x00, // Interlace method + 0x00, 0x00, 0x00, 0x00, // Reserve to write CRC-32 + ]; + + BinaryPrimitives.WriteInt32BigEndian(headerBytes[16..], _width); + BinaryPrimitives.WriteInt32BigEndian(headerBytes[20..], _height); + var crc = Crc32.Compute(headerBytes.Slice(12, 17)); + + BinaryPrimitives.WriteUInt32BigEndian(headerBytes[29..], crc); + + _outputPipe.Write(headerBytes); + } + + private unsafe void Initialize() + { + if (_memoryHandle == null) + { + var memory = _outputPipe.GetMemory(PipeChunkThreshold); + var handle = memory.Pin(); + _memoryStream.Initialize((byte*)handle.Pointer, 0, memory.Length); + _memoryHandle = handle; + + _memoryStream.SetLength(8); + _memoryStream.Position = 8; + _zlibStream = new ZLibStream(_memoryStream, CompressionLevel.Optimal, true); + } + } + + public async Task WriteDataAsync(IBuffer buffer, bool disposeBuffer = true, CancellationToken cancellationToken = default) + { + Initialize(); + _zlibStream!.Write([0]); + + var offset = 0; + while (buffer.Length - offset > FlushThreshold) + { + _zlibStream.Write(buffer.Span.Slice(offset, FlushThreshold)); + await _zlibStream.FlushAsync(cancellationToken); + offset += FlushThreshold; + if(_memoryStream.Length >= BufferSize) + await FlushAsync(cancellationToken); + } + + if (buffer.Length > offset) + { + _zlibStream.Write(buffer.Span[offset..]); + await _zlibStream.FlushAsync(cancellationToken); + _shouldFlush = true; + } + + if(disposeBuffer) buffer.Dispose(); + } + + private async Task FlushAsync(CancellationToken cancellationToken) + { + await _zlibStream!.FlushAsync(cancellationToken); + var dataSize = (int)(_memoryStream.Length - 8); + + _memoryStream.Position = 0; + Span buffer = stackalloc byte[4]; + BinaryPrimitives.WriteInt32BigEndian(buffer, dataSize); + _memoryStream.Write(buffer); + _memoryStream.Write("IDAT"u8); + + _memoryStream.Position = 4; + + // write Crc + var crc = Crc32.Compute(_memoryStream, dataSize + 4); + BinaryPrimitives.WriteUInt32BigEndian(buffer, crc); + _memoryStream.Write(buffer); + + _outputPipe.Advance((int)_memoryStream.Length); + + await _memoryStream.DisposeAsync(); + _memoryHandle!.Value.Dispose(); + _memoryHandle = null; + + _shouldFlush = false; + } + + public async Task WriteEndOfFileAsync(CancellationToken cancellationToken = default) + { + if(_shouldFlush) + await FlushAsync(cancellationToken); + + Span endChunk = [ + 0x00, 0x00, 0x00, 0x00, // Length + 0x49, 0x45, 0x4E, 0x44, // IEND + 0xAE, 0x42, 0x60, 0x82, // Crc + ]; + + _outputPipe.Write(endChunk); + Dispose(); + } + + public void Dispose() + { + if (!_disposed) + { + if (_memoryHandle != null) + { + _zlibStream!.Dispose(); + _memoryStream.Dispose(); + } + _disposed = true; + GC.SuppressFinalize(this); + } + } + + private unsafe class RawPointerStream : UnmanagedMemoryStream + { + public void Initialize(byte* pointer, int length, int capacity) + { + Initialize(pointer, length, capacity, FileAccess.ReadWrite); + } + } +} \ No newline at end of file diff --git a/Infra/ImageIntegral.cs b/Infra/ImageIntegral.cs index 8819ea2..0a884ce 100644 --- a/Infra/ImageIntegral.cs +++ b/Infra/ImageIntegral.cs @@ -1,4 +1,3 @@ -using System.Buffers; using System.IO.MemoryMappedFiles; using System.Runtime.CompilerServices; using System.Runtime.InteropServices; @@ -19,7 +18,7 @@ public class ImageIntegral : IDisposable private readonly int _width; private readonly int _height; - private IMemoryOwner? _rowLocks; + private ManualResetEventSlim[]? _rowLocks; private MemoryMappedFile? _memoryMappedFile; private readonly Lock _lock = new(); @@ -66,7 +65,7 @@ public class ImageIntegral : IDisposable } } - _rowLocks?.Memory.Span[row].Wait(cancellationToken); + _rowLocks?[row].Wait(cancellationToken); } private void Initialize(CancellationToken cancellationToken) @@ -101,12 +100,11 @@ public class ImageIntegral : IDisposable // initialize resource gating, all rows is expected to be locked // if the backed file require to allocate, it should be safe to do this // asynchronously - var rowLocks = MemoryAllocator.AllocateManaged(_height); - var rowLocksSpan = rowLocks.Memory.Span; + var rowLocks = new ManualResetEventSlim[_height]; for (int i = 0; i < _height; i++) { var isOpen = i < header.ProcessedRows; - rowLocksSpan[i] = new ManualResetEventSlim(isOpen); + rowLocks[i] = new ManualResetEventSlim(isOpen); } _rowLocks = rowLocks; @@ -222,7 +220,7 @@ public class ImageIntegral : IDisposable view.DangerousWriteSpan(0, writeBuffer.Span, 0, _width); writeBuffer.Dispose(); - _rowLocks!.Memory.Span[row].Set(); + _rowLocks![row].Set(); Interlocked.Increment(ref _processedRows); using (var view = AcquireHeaderView(MemoryMappedFileAccess.Write)) @@ -370,11 +368,8 @@ public class ImageIntegral : IDisposable if (_rowLocks is { } locks) { _rowLocks = null; - var lockSpan = locks.Memory.Span; for(int i = 0; i < _height; i++) - lockSpan[i].Dispose(); - - locks.Dispose(); + locks[i].Dispose(); } } diff --git a/Infra/Int32Pixel.cs b/Infra/Int32Pixel.cs index 836d378..1078a60 100644 --- a/Infra/Int32Pixel.cs +++ b/Infra/Int32Pixel.cs @@ -68,6 +68,12 @@ public record struct Int32Pixel return new Int32Pixel(a.R / b, a.G / b, a.B / b); } + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static Int32Pixel operator /(Int32Pixel a, float b) + { + return new Int32Pixel((byte)(a.R / b), (byte)(a.G / b), (byte)(a.B / b)); + } + [MethodImpl(MethodImplOptions.AggressiveInlining)] public static explicit operator Int32Pixel(Rgb24 pixel) { diff --git a/StitchATon2.Benchmark/SingleTileBenchmark.cs b/StitchATon2.Benchmark/SingleTileBenchmark.cs new file mode 100644 index 0000000..2e806d4 --- /dev/null +++ b/StitchATon2.Benchmark/SingleTileBenchmark.cs @@ -0,0 +1,77 @@ +using System.Net.Http.Json; +using BenchmarkDotNet.Attributes; +using StitchATon2.Domain; + +namespace StitchATon2.Benchmark; + +public class SingleTileBenchmark +{ + private const string Url = "http://localhost:5088/api/image/generate"; + private readonly TileManager _tileManager = new(Configuration.Default); + private readonly Random _random = new(); + private readonly HttpClient _client = new(); + + private string GetRandomCoordinatePair() + { + var maxId = _tileManager.Configuration.Rows + * _tileManager.Configuration.Columns; + + var id = _random.Next(maxId); + var tile = _tileManager.GetTile(id); + return $"{tile.Coordinate}:{tile.Coordinate}"; + } + + private JsonContent GetRandomDto(float scale, float offsetX = 0, float offsetY = 0) + { + return JsonContent.Create(new + { + canvas_rect = GetRandomCoordinatePair(), + crop_offset = (float[]) [offsetX, offsetY], + crop_size = (float[]) [1f - offsetX, 1f - offsetY], + output_scale = scale, + }); + } + + private JsonContent GetRandomDtoWithOffset(float scale) + { + var offsetX = _random.NextSingle(); + var offsetY = _random.NextSingle(); + return GetRandomDto(scale, offsetX, offsetY); + } + + [Benchmark] + public async Task NoScaling() + { + await _client.PostAsync(Url, GetRandomDto(1f)); + } + + [Benchmark] + public async Task ScaleHalf() + { + await _client.PostAsync(Url, GetRandomDto(.5f)); + } + + [Benchmark] + public async Task ScaleQuarter() + { + await _client.PostAsync(Url, GetRandomDto(.25f)); + } + + [Benchmark] + public async Task NoScalingWithOffset() + { + await _client.PostAsync(Url, GetRandomDtoWithOffset(1f)); + } + + [Benchmark] + public async Task ScaleHalfWithOffset() + { + await _client.PostAsync(Url, GetRandomDtoWithOffset(.5f)); + } + + [Benchmark] + public async Task ScaleQuarterWithOffset() + { + await _client.PostAsync(Url, GetRandomDtoWithOffset(.25f)); + } +} \ No newline at end of file diff --git a/StitchATon2.sln b/StitchATon2.sln index 31e5c57..702b5a5 100644 --- a/StitchATon2.sln +++ b/StitchATon2.sln @@ -6,6 +6,8 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StitchATon2.Domain", "Domai EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StitchATon2.Infra", "Infra\StitchATon2.Infra.csproj", "{E602F3FC-6139-4B30-AC5A-75815E6340A4}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StitchATon2.Benchmark", "StitchATon2.Benchmark\StitchATon2.Benchmark.csproj", "{2F9B169C-C799-4489-B864-F912D69C5D3E}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -24,5 +26,9 @@ Global {E602F3FC-6139-4B30-AC5A-75815E6340A4}.Debug|Any CPU.Build.0 = Debug|Any CPU {E602F3FC-6139-4B30-AC5A-75815E6340A4}.Release|Any CPU.ActiveCfg = Release|Any CPU {E602F3FC-6139-4B30-AC5A-75815E6340A4}.Release|Any CPU.Build.0 = Release|Any CPU + {2F9B169C-C799-4489-B864-F912D69C5D3E}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {2F9B169C-C799-4489-B864-F912D69C5D3E}.Debug|Any CPU.Build.0 = Debug|Any CPU + {2F9B169C-C799-4489-B864-F912D69C5D3E}.Release|Any CPU.ActiveCfg = Release|Any CPU + {2F9B169C-C799-4489-B864-F912D69C5D3E}.Release|Any CPU.Build.0 = Release|Any CPU EndGlobalSection EndGlobal