Files
LlamaCasty/ytLive.Tests/ChromaKeyDetectorTests.cs
T
gramps 21db8a17b8 feat(chroma): detect a green or blue screen and build the key filter
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).
2026-11-05 08:05:18 -08:00

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);
}
}