feat(pipeline): add managed BGRA nearest-neighbor scaler with aspect modes
Some checks failed
CI / build-and-test (push) Failing after 27s
Some checks failed
CI / build-and-test (push) Failing after 27s
This commit is contained in:
parent
af37b4d9e1
commit
88841780af
2 changed files with 167 additions and 0 deletions
|
|
@ -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}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Reference in a new issue