TASK 64 units B and C — the two pure halves, no wiring yet. - `Services/ChromaKeyDetector.cs`: samples the frame's outer ring (the screen is at the edges, the subject in the middle), reports Green/Blue/None plus the colour actually shot and a confidence. Confidence is coverage × hue tightness, which is what keeps a lawn or a football pitch from being offered as a keyable screen. Letterbox bars are excluded from the denominator rather than counted against the screen. Never decides anything: an offer is the creator's to accept. - `Services/VideoFilterChain.cs`: options → `-vf` (scale → colorkey → despill → format=bgra) plus a `Signature` so two layers keyed identically can share one decoder. Numbers clamp and an unparseable colour falls back to the app default, so ffmpeg is never handed a filter it rejects at spawn. - Keying is done by the bundled ffmpeg, not by us: verified against the shipped binary that `colorkey=…,format=bgra` emits straight-alpha BGRA (alpha 0 on keyed pixels), which is what the compositor already blends. No per-pixel loop lands in the 16.67 ms render budget. Test: `ChromaKeyDetectorTests` — green, blue, letterboxed, lawn (must say None), mixed scene, dark frame, plus the chain strings and signature sharing. Docs: TASK 64 queued in TASKS.md with its slice table; the decisions.md chroma-key reversal recorded as a reversal (it was buried for webcam background removal, not for supplied clips).
158 lines
5.8 KiB
C#
158 lines
5.8 KiB
C#
using System;
|
|
using System.Collections.Generic;
|
|
using Xunit;
|
|
using ytLive.Services;
|
|
|
|
namespace ytLive.Tests;
|
|
|
|
/// <summary>
|
|
/// TASK 64 unit tests (pure): does the detector recognise a real green/blue screen, and does it
|
|
/// keep its mouth shut about things that merely contain green? Plus the decoder filter chain the
|
|
/// key turns into — asserted as a string, the same way the encoder's ffmpeg args are pinned.
|
|
/// </summary>
|
|
public class ChromaKeyDetectorTests
|
|
{
|
|
private const byte GreenR = 0x00, GreenG = 0xB1, GreenB = 0x40; // #00B140 — a real screen
|
|
private const byte BlueR = 0x00, BlueG = 0x47, BlueB = 0xBB; // #0047BB
|
|
|
|
[Fact]
|
|
public void GreenScreen_IsDetected_WithTheColourActuallyShot()
|
|
{
|
|
var frame = Build(120, 68, (x, y) => InCentre(x, y, 120, 68) ? Skin() : (GreenR, GreenG, GreenB));
|
|
|
|
var suggestion = ChromaKeyDetector.Detect(frame);
|
|
|
|
Assert.Equal(ChromaScreen.Green, suggestion.Screen);
|
|
Assert.True(suggestion.IsHighConfidence, $"confidence {suggestion.Confidence:0.00} should be high");
|
|
Assert.Equal("#00B140", suggestion.ColorHex);
|
|
}
|
|
|
|
[Fact]
|
|
public void BlueScreen_IsDetected()
|
|
{
|
|
var frame = Build(120, 68, (x, y) => InCentre(x, y, 120, 68) ? Skin() : (BlueR, BlueG, BlueB));
|
|
|
|
var suggestion = ChromaKeyDetector.Detect(frame);
|
|
|
|
Assert.Equal(ChromaScreen.Blue, suggestion.Screen);
|
|
Assert.True(suggestion.IsHighConfidence);
|
|
}
|
|
|
|
[Fact]
|
|
public void LetterboxedScreen_IsStillDetected()
|
|
{
|
|
// The clip is wider than the screen: black bars top and bottom. Bars must not count
|
|
// against the screen, or every letterboxed clip reads as "no backdrop".
|
|
var frame = Build(120, 68, (x, y) =>
|
|
y < 12 || y >= 56 ? ((byte)0, (byte)0, (byte)0) : (GreenR, GreenG, GreenB));
|
|
|
|
var suggestion = ChromaKeyDetector.Detect(frame);
|
|
|
|
Assert.Equal(ChromaScreen.Green, suggestion.Screen);
|
|
Assert.True(suggestion.Confidence >= ChromaKeySuggestion.SuggestFloor,
|
|
$"letterboxing dropped confidence to {suggestion.Confidence:0.00}");
|
|
}
|
|
|
|
[Fact]
|
|
public void LawnOrPitch_SaysNothing()
|
|
{
|
|
// Green, but the greens disagree with each other (hue 100 vs 145 alternating). Keying
|
|
// this would eat the picture, so "None" is the correct answer.
|
|
var frame = Build(120, 68, (x, y) => (x / 4 + y / 4) % 2 == 0
|
|
? ((byte)20, (byte)150, (byte)30) // hue ≈ 125, dark-ish grass
|
|
: ((byte)80, (byte)180, (byte)10)); // hue ≈ 95, sunlit grass
|
|
|
|
var suggestion = ChromaKeyDetector.Detect(frame);
|
|
|
|
Assert.Equal(ChromaScreen.None, suggestion.Screen);
|
|
}
|
|
|
|
[Fact]
|
|
public void MixedScene_SaysNothing()
|
|
{
|
|
var frame = Build(120, 68, (x, y) => ((byte)(x % 255), (byte)(y % 255), (byte)((x + y) % 255)));
|
|
|
|
var suggestion = ChromaKeyDetector.Detect(frame);
|
|
|
|
Assert.Equal(ChromaScreen.None, suggestion.Screen);
|
|
}
|
|
|
|
[Fact]
|
|
public void DarkFrames_AreNotAScreen()
|
|
{
|
|
var frame = Build(120, 68, (x, y) => ((byte)0, (byte)(y % 20), (byte)0));
|
|
|
|
Assert.Equal(ChromaScreen.None, ChromaKeyDetector.Detect(frame).Screen);
|
|
}
|
|
|
|
// ─── the filter chain the key turns into ───
|
|
|
|
[Fact]
|
|
public void UnkeyedClip_KeepsThePlainScaleChain()
|
|
=> Assert.Equal("scale=1920:1080", VideoFilterChain.BuildVideo(1920, 1080, ChromaKeyOptions.Off));
|
|
|
|
[Fact]
|
|
public void KeyedClip_ScalesThenKeysThenDespillsThenForcesBgra()
|
|
{
|
|
var options = new ChromaKeyOptions(true, "#00B140", 0.12, 0.08, Spill: true);
|
|
|
|
Assert.Equal(
|
|
"scale=1920:1080,colorkey=0x00B140:0.12:0.08,despill=type=green,format=bgra",
|
|
VideoFilterChain.BuildVideo(1920, 1080, options));
|
|
}
|
|
|
|
[Fact]
|
|
public void BlueScreen_DespillsBlue()
|
|
{
|
|
var options = new ChromaKeyOptions(true, "#0047BB", 0.1, 0.05, Spill: true, ChromaScreen.Blue);
|
|
|
|
Assert.Contains("despill=type=blue", VideoFilterChain.BuildVideo(1920, 1080, options));
|
|
}
|
|
|
|
[Fact]
|
|
public void SpillOff_AndBadColour_StillProduceAValidChain()
|
|
{
|
|
var noSpill = new ChromaKeyOptions(true, "not-a-colour", 2.0, -1, Spill: false);
|
|
var chain = VideoFilterChain.BuildVideo(1280, 720, noSpill);
|
|
|
|
// Out-of-range numbers clamp, an unparseable colour falls back to the app default, and
|
|
// no despill term is appended — ffmpeg must never be handed a filter it will reject.
|
|
Assert.Equal("scale=1280:720,colorkey=0x00B140:1:0,format=bgra", chain);
|
|
}
|
|
|
|
[Fact]
|
|
public void Signature_IsTheSameForIdenticalLooks_AndDiffersOtherwise()
|
|
{
|
|
var a = new ChromaKeyOptions(true, "#00b140", 0.12, 0.08, true);
|
|
var b = new ChromaKeyOptions(true, "#00B140", 0.12, 0.08, true);
|
|
var c = new ChromaKeyOptions(true, "#00B140", 0.30, 0.08, true);
|
|
|
|
Assert.Equal(a.Signature, b.Signature); // shared decoder for identical looks
|
|
Assert.NotEqual(a.Signature, c.Signature);
|
|
Assert.Equal("off", ChromaKeyOptions.Off.Signature);
|
|
}
|
|
|
|
// ─── helpers ───
|
|
|
|
private static bool InCentre(int x, int y, int w, int h)
|
|
=> x > w * 0.35 && x < w * 0.65 && y > h * 0.25 && y < h * 0.75;
|
|
|
|
private static (byte R, byte G, byte B) Skin() => (0xE0, 0xB0, 0x90);
|
|
|
|
private static VideoFrame Build(int width, int height, Func<int, int, (byte R, byte G, byte B)> pixel)
|
|
{
|
|
var bytes = new byte[width * height * 4];
|
|
for (var y = 0; y < height; y++)
|
|
for (var x = 0; x < width; x++)
|
|
{
|
|
var (r, g, b) = pixel(x, y);
|
|
var i = (y * width + x) * 4;
|
|
bytes[i] = b;
|
|
bytes[i + 1] = g;
|
|
bytes[i + 2] = r;
|
|
bytes[i + 3] = 255;
|
|
}
|
|
return new VideoFrame(width, height, bytes);
|
|
}
|
|
}
|