feat(pipeline): add managed BGRA nearest-neighbor scaler with aspect modes
Some checks failed
CI / build-and-test (push) Failing after 27s

This commit is contained in:
Zac Gaetano 2026-05-07 15:37:07 +00:00
parent af37b4d9e1
commit 88841780af
2 changed files with 167 additions and 0 deletions

View file

@ -0,0 +1,95 @@
using TeamsISO.Engine.Domain;
namespace TeamsISO.Engine.Pipeline;
/// <summary>
/// Pure-managed BGRA frame scaler with nearest-neighbor sampling and aspect-mode handling.
/// Suitable for v1.0 ship; libyuv is a v1.5 perf optimization (per spec §6 deferred items).
/// </summary>
public sealed class ManagedNearestNeighborFrameScaler : IFrameScaler
{
public ProcessedFrame Scale(RawFrame source, int targetWidth, int targetHeight, AspectMode aspect, long timestampTicks)
{
if (source.Format != PixelFormat.Bgra)
throw new NotSupportedException(
$"ManagedNearestNeighborFrameScaler only supports BGRA input (got {source.Format}). " +
"v1.0 receivers request BGRA from NDI; UYVY support comes in v1.5.");
var output = new byte[targetWidth * targetHeight * 4];
// Compute the destination rectangle within the target according to aspect mode.
var (drawX, drawY, drawW, drawH) = ComputeFitRect(
source.Width, source.Height, targetWidth, targetHeight, aspect);
// Pre-fill with black if there will be unfilled bars (Pillarbox/Letterbox).
if (drawX > 0 || drawY > 0 || drawW < targetWidth || drawH < targetHeight)
{
for (var i = 3; i < output.Length; i += 4) output[i] = 0xFF; // alpha
// BGR remain 0x00 from default initialization.
}
var srcSpan = source.Pixels.Span;
var dstSpan = output.AsSpan();
// Nearest-neighbor scale into the (drawX, drawY, drawW, drawH) region.
for (var dy = 0; dy < drawH; dy++)
{
// Map dest-row inside drawn rect → source row
var sy = (int)((long)dy * source.Height / drawH);
if (sy >= source.Height) sy = source.Height - 1;
var srcRowStart = sy * source.Width * 4;
var dstRowStart = ((drawY + dy) * targetWidth + drawX) * 4;
for (var dx = 0; dx < drawW; dx++)
{
var sx = (int)((long)dx * source.Width / drawW);
if (sx >= source.Width) sx = source.Width - 1;
var srcOffset = srcRowStart + sx * 4;
var dstOffset = dstRowStart + dx * 4;
dstSpan[dstOffset + 0] = srcSpan[srcOffset + 0];
dstSpan[dstOffset + 1] = srcSpan[srcOffset + 1];
dstSpan[dstOffset + 2] = srcSpan[srcOffset + 2];
dstSpan[dstOffset + 3] = srcSpan[srcOffset + 3];
}
}
return new ProcessedFrame(targetWidth, targetHeight, timestampTicks, output, PixelFormat.Bgra);
}
private static (int X, int Y, int W, int H) ComputeFitRect(int srcW, int srcH, int dstW, int dstH, AspectMode aspect)
{
switch (aspect)
{
case AspectMode.Stretch:
return (0, 0, dstW, dstH);
case AspectMode.Pillarbox:
case AspectMode.Letterbox:
{
// Both modes preserve aspect ratio and fit within the destination — the difference is
// descriptive (which dimension gets bars), but the math is the same: scale to fit.
var srcAspect = (double)srcW / srcH;
var dstAspect = (double)dstW / dstH;
int w, h;
if (srcAspect > dstAspect)
{
// Source is wider → letterbox (fill width, bars top/bottom).
w = dstW;
h = (int)Math.Round(dstW / srcAspect);
}
else
{
// Source is taller/narrower → pillarbox (fill height, bars left/right).
h = dstH;
w = (int)Math.Round(dstH * srcAspect);
}
var x = (dstW - w) / 2;
var y = (dstH - h) / 2;
return (x, y, w, h);
}
default:
throw new InvalidOperationException($"Unknown aspect mode: {aspect}");
}
}
}

View file

@ -0,0 +1,72 @@
using TeamsISO.Engine.Domain;
using TeamsISO.Engine.Pipeline;
namespace TeamsISO.Engine.Tests.Pipeline;
public class ManagedNearestNeighborFrameScalerTests
{
private static RawFrame Solid(int w, int h, byte b, byte g, byte r, byte a, long ts = 0)
{
var pixels = new byte[w * h * 4];
for (var i = 0; i < pixels.Length; i += 4)
{
pixels[i + 0] = b; pixels[i + 1] = g; pixels[i + 2] = r; pixels[i + 3] = a;
}
return new RawFrame(w, h, ts, pixels, PixelFormat.Bgra);
}
[Fact]
public void Stretch_ScalesToTargetDimensions()
{
var scaler = new ManagedNearestNeighborFrameScaler();
var src = Solid(640, 360, 0x10, 0x20, 0x30, 0xFF);
var dst = scaler.Scale(src, 1920, 1080, AspectMode.Stretch, 1234);
dst.Width.Should().Be(1920);
dst.Height.Should().Be(1080);
dst.Format.Should().Be(PixelFormat.Bgra);
dst.Pixels.Length.Should().Be(1920 * 1080 * 4);
dst.TimestampTicks.Should().Be(1234);
// Solid color should be preserved across the entire output
dst.Pixels.Span[0].Should().Be(0x10);
dst.Pixels.Span[dst.Pixels.Length - 4].Should().Be(0x10);
}
[Fact]
public void Pillarbox_NarrowSource_GetsBlackBars_LeftAndRight()
{
var scaler = new ManagedNearestNeighborFrameScaler();
// 1:1 aspect source going into a 16:9 target should pillarbox.
var src = Solid(100, 100, 0x10, 0x20, 0x30, 0xFF);
var dst = scaler.Scale(src, 1920, 1080, AspectMode.Pillarbox, 0);
// Top-left pixel (in the bar region) should be black.
dst.Pixels.Span[0].Should().Be(0x00);
dst.Pixels.Span[1].Should().Be(0x00);
dst.Pixels.Span[2].Should().Be(0x00);
// Center pixel should carry the source color.
var centerOffset = (1080 / 2 * 1920 + 1920 / 2) * 4;
dst.Pixels.Span[centerOffset].Should().Be(0x10);
dst.Pixels.Span[centerOffset + 2].Should().Be(0x30);
}
[Fact]
public void Letterbox_WideSource_GetsBlackBars_TopAndBottom()
{
var scaler = new ManagedNearestNeighborFrameScaler();
// 21:9 source (e.g. 2100x900) into a 16:9 target should letterbox.
var src = Solid(2100, 900, 0x10, 0x20, 0x30, 0xFF);
var dst = scaler.Scale(src, 1920, 1080, AspectMode.Letterbox, 0);
// Top-left pixel should be in the bar region (black).
dst.Pixels.Span[0].Should().Be(0x00);
// Center pixel should carry the source color.
var centerOffset = (1080 / 2 * 1920 + 1920 / 2) * 4;
dst.Pixels.Span[centerOffset].Should().Be(0x10);
}
}