Files
LlamaCasty/ytLive.Tests/BackgroundEffectEngineTests.cs
T

225 lines
9.8 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
using System.IO;
using System.Windows.Media;
using System.Windows.Media.Imaging;
using Xunit;
using ytLive.Models;
using ytLive.Services;
using ytLive.Services.BackgroundEffects;
namespace ytLive.Tests;
/// <summary>TASK 51 slice 1 — the background-effects engine.</summary>
public class BackgroundEffectEngineTests
{
private sealed class FakeSegmenter : IPersonSegmenter
{
private readonly SegmentationMask? _mask;
public int Calls;
public FakeSegmenter(SegmentationMask? mask) => _mask = mask;
public SegmentationMask? Segment(VideoFrame frame)
{
Calls++;
return _mask;
}
}
/// <summary>The integration test for this unit: a webcam frame run through the
/// processor with Remove comes back with the person kept and the background
/// transparent, colors untouched, flagged non-opaque so the compositor blends it.
/// None is a true passthrough (same instance, segmenter never called).</summary>
[Fact]
public void Remove_keeps_the_person_and_makes_the_background_transparent()
{
const int w = 64, h = 32;
var pixels = new byte[w * h * 4];
for (var i = 0; i < pixels.Length; i += 4)
{
pixels[i] = 10; pixels[i + 1] = 20; pixels[i + 2] = 30; pixels[i + 3] = 255;
}
var frame = new VideoFrame(w, h, pixels) { IsOpaque = true };
// 4×2 mask: left half person, right half background.
var mask = new SegmentationMask(4, 2, new[] { 1f, 1f, 0f, 0f, 1f, 1f, 0f, 0f });
var segmenter = new FakeSegmenter(mask);
var processor = new BackgroundEffectProcessor(segmenter);
Assert.Same(frame, processor.Apply(frame, BackgroundEffect.None));
Assert.Equal(0, segmenter.Calls);
var result = processor.Apply(frame, BackgroundEffect.Remove);
Assert.NotSame(frame, result);
Assert.False(result.IsOpaque);
Assert.Equal(w, result.Width);
Assert.Equal(h, result.Height);
for (var y = 0; y < h; y++)
{
Assert.Equal(255, Alpha(result, 4, y)); // well inside the person half
Assert.Equal(0, Alpha(result, w - 5, y)); // well inside the background half
}
for (var i = 0; i < pixels.Length; i += 4)
{
Assert.Equal(pixels[i], result.BgraPixels[i]);
Assert.Equal(pixels[i + 1], result.BgraPixels[i + 1]);
Assert.Equal(pixels[i + 2], result.BgraPixels[i + 2]);
}
Assert.Equal(255, frame.BgraPixels[(w - 5) * 4 + 3]); // source frame never mutated
}
/// <summary>Model contract: the BUNDLED (embedded) ONNX model, driven through the C#
/// preprocessing, reproduces Google's own MediaPipe runtime on a fixed fixture.
/// The expected mask was produced by tools/selfie-seg/ running the original .tflite in
/// MediaPipe (fixture: MediaPipe's public test image mozart_square.jpg, a public-domain
/// painting, cropped to 256×144). Guards the embedding, the input layout (RGB, NHWC,
/// /255) and the conversion itself.</summary>
[Fact]
public void Bundled_model_matches_the_MediaPipe_reference_mask()
{
var frame = LoadBgra(Fixture("selfie-fixture-256x144.png"));
var expected = LoadBgra(Fixture("selfie-fixture-expected-mask.png"));
using var segmenter = new OnnxPersonSegmenter();
var mask = segmenter.Segment(frame);
Assert.NotNull(mask);
Assert.Equal(OnnxPersonSegmenter.ModelWidth, mask!.Width);
Assert.Equal(OnnxPersonSegmenter.ModelHeight, mask.Height);
var agree = 0;
var person = 0;
for (var i = 0; i < mask.Width * mask.Height; i++)
{
var expectPerson = expected.BgraPixels[i * 4] > 127;
var gotPerson = mask.Values[i] > 0.5f;
if (expectPerson == gotPerson) agree++;
if (gotPerson) person++;
}
var total = mask.Width * mask.Height;
Assert.True(agree >= total * 0.995, $"binary agreement {100.0 * agree / total:0.00}% < 99.5%");
Assert.InRange(100.0 * person / total, 45, 60); // reference: 52.5% person
}
/// <summary>Slice 2 integration test: Blur keeps the person pixel-for-pixel and smooths the
/// background — a 1px black/white checkerboard behind the "person" comes back as mid-tones,
/// opaque, same size.</summary>
[Fact]
public void Blur_keeps_the_person_sharp_and_smooths_the_background()
{
const int w = 64, h = 32;
var pixels = new byte[w * h * 4];
for (var y = 0; y < h; y++)
for (var x = 0; x < w; x++)
{
var p = (y * w + x) * 4;
if (x < w / 2)
{
pixels[p] = 100; pixels[p + 1] = 150; pixels[p + 2] = 200;
}
else
{
var v = (byte)(((x + y) & 1) == 0 ? 0 : 255);
pixels[p] = v; pixels[p + 1] = v; pixels[p + 2] = v;
}
pixels[p + 3] = 255;
}
var frame = new VideoFrame(w, h, pixels) { IsOpaque = true };
var mask = new SegmentationMask(4, 2, new[] { 1f, 1f, 0f, 0f, 1f, 1f, 0f, 0f });
var processor = new BackgroundEffectProcessor(new FakeSegmenter(mask));
var result = processor.Apply(frame, BackgroundEffect.Blur);
Assert.NotSame(frame, result);
Assert.True(result.IsOpaque);
Assert.Equal(w, result.Width);
Assert.Equal(h, result.Height);
for (var y = 0; y < h; y++)
{
for (var x = 0; x < 24; x++) // person: untouched
for (var c = 0; c < 4; c++)
Assert.Equal(pixels[(y * w + x) * 4 + c], result.BgraPixels[(y * w + x) * 4 + c]);
for (var x = 44; x < w; x++) // background: checkerboard smoothed away
{
var p = (y * w + x) * 4;
for (var c = 0; c < 3; c++)
Assert.InRange(result.BgraPixels[p + c], (byte)30, (byte)225);
Assert.Equal(255, result.BgraPixels[p + 3]);
}
}
}
/// <summary>TASK 51 blur-heavy fix — the integration test: an all-background frame with one
/// bright pixel. The default 0.5 strength must keep the halo tight (quadratic curve: radius 2
/// small px) while full strength spreads it far (radius 6) — the maker complained the default
/// was heavy under the old linear 1..12 range.</summary>
[Fact]
public void Blur_default_strength_is_gentle_and_full_strength_is_strong()
{
const int w = 128, h = 64;
var pixels = new byte[w * h * 4];
for (var y = 0; y < h; y++)
for (var x = 0; x < w; x++)
{
var p = (y * w + x) * 4;
if (x < w / 2) pixels[p] = 200;
pixels[p + 1] = 180;
pixels[p + 2] = 160;
pixels[p + 3] = 255;
}
var frame = new VideoFrame(w, h, pixels) { IsOpaque = true };
// All background — the whole frame blurs, no person boundary to confuse the edge spread.
var mask = new SegmentationMask(4, 2, new float[8]);
var processor = new BackgroundEffectProcessor(new FakeSegmenter(mask));
var gentle = processor.Apply(frame, BackgroundEffect.Blur, BackgroundEffectProcessor.DefaultStrength);
var strong = processor.Apply(frame, BackgroundEffect.Blur, 1.0);
// The radius mapping itself: quadratic, not the old linear 1..12. Default 0.5 = 2 small px
// (was 6), max = 6 small px (was 12) — the reason the default felt heavy before.
Assert.Equal(1, BackgroundEffectProcessor.BlurRadius(0.0));
Assert.Equal(2, BackgroundEffectProcessor.BlurRadius(BackgroundEffectProcessor.DefaultStrength));
Assert.Equal(BackgroundEffectProcessor.MinBlurRadius, BackgroundEffectProcessor.BlurRadius(0.0));
Assert.Equal(BackgroundEffectProcessor.MaxBlurRadius, BackgroundEffectProcessor.BlurRadius(1.0));
// Behaviour at a step edge: the default smears light only ~9px into the dark half (measured),
// full strength smears ~26px — a gentle default with a real range above it.
var (reachGentle, sumGentle) = EdgeReach(gentle);
var (reachStrong, sumStrong) = EdgeReach(strong);
Assert.True(reachGentle <= 12, $"default blur reach {reachGentle}px (expected <= 12)");
Assert.True(reachStrong >= 20, $"max blur reach {reachStrong}px (expected >= 20)");
Assert.True(reachStrong > reachGentle);
Assert.True(sumStrong > sumGentle * 2, "max strength should smear visibly more light than default");
}
/// <summary>Largest x into the dark half (x = w/2) still carrying light, and the total light
/// in the dark half — the blur's smearing distance at a step edge.</summary>
private static (int Reach, double Sum) EdgeReach(VideoFrame f)
{
var edge = f.Width / 2;
var reach = 0;
double sum = 0;
for (var y = 0; y < f.Height; y++)
for (var x = edge; x < f.Width; x++)
{
var v = f.BgraPixels[(y * f.Width + x) * 4];
if (v >= 32) reach = Math.Max(reach, x - edge);
sum += v;
}
return (reach, sum);
}
private static int Alpha(VideoFrame f, int x, int y) => f.BgraPixels[(y * f.Width + x) * 4 + 3];
private static string Fixture(string name) => Path.Combine(AppContext.BaseDirectory, "Fixtures", name);
private static VideoFrame LoadBgra(string path)
{
using var stream = File.OpenRead(path);
var decoded = BitmapDecoder.Create(stream, BitmapCreateOptions.PreservePixelFormat, BitmapCacheOption.OnLoad).Frames[0];
var bgra = new FormatConvertedBitmap(decoded, PixelFormats.Bgra32, null, 0);
var pixels = new byte[bgra.PixelWidth * bgra.PixelHeight * 4];
bgra.CopyPixels(pixels, bgra.PixelWidth * 4, 0);
return new VideoFrame(bgra.PixelWidth, bgra.PixelHeight, pixels);
}
}