diff --git a/Services/BackgroundEffects/BackgroundEffectProcessor.cs b/Services/BackgroundEffects/BackgroundEffectProcessor.cs
index b026ac5..e9ee580 100644
--- a/Services/BackgroundEffects/BackgroundEffectProcessor.cs
+++ b/Services/BackgroundEffects/BackgroundEffectProcessor.cs
@@ -6,24 +6,36 @@ namespace ytLive.Services.BackgroundEffects;
///
/// Applies a webcam's to one captured frame (TASK 51).
/// Pure pixel work over the seam — WPF-free and thread-agnostic,
-/// so the camera worker thread can call it before the frame reaches the preview bitmap
+/// so the camera's processing worker can call it before the frame reaches the preview bitmap
/// and the compositor.
///
///
/// - returns the SAME frame instance — a true
/// passthrough, no copy.
-/// - returns a copy whose alpha is the person
-/// mask (bilinear-upscaled), RGB untouched, IsOpaque = false — the compositor's
-/// straight-alpha blend then shows the layers below the webcam.
-/// - is the next slice; until it lands it passes
-/// through.
+/// - returns a copy whose alpha is the person mask,
+/// RGB untouched, IsOpaque = false — the compositor's straight-alpha blend then shows
+/// the layers below the webcam. Strength = edge softness (0 = crisp cut-out, 1 = the model's
+/// raw soft mask).
+/// - returns a copy where the background is replaced
+/// by a blurred version of itself and the person stays sharp. Strength = blur amount.
///
///
-/// A segmenter that returns no mask (or throws) never drops the layer: the frame passes
-/// through and the failure is logged once.
+/// The mask is computed ONCE per camera frame () and then rendered for
+/// every effect variant in use (). A segmenter that returns no mask (or
+/// throws) never drops the layer: the frame passes through and the failure is logged once.
///
public sealed class BackgroundEffectProcessor
{
+ public const double DefaultStrength = 0.5;
+
+ /// The background is blurred at 1/ resolution and
+ /// upsampled while blending — a strong blur at a fraction of the cost.
+ internal const int BlurDownscale = 4;
+
+ /// Box-blur radius (in downscaled pixels) at strength 0 and 1.
+ internal const int MinBlurRadius = 1;
+ internal const int MaxBlurRadius = 12;
+
private readonly IPersonSegmenter _segmenter;
private bool _loggedFailure;
@@ -32,10 +44,16 @@ public sealed class BackgroundEffectProcessor
_segmenter = segmenter;
}
- public VideoFrame Apply(VideoFrame frame, BackgroundEffect effect)
+ public VideoFrame Apply(VideoFrame frame, BackgroundEffect effect, double strength = DefaultStrength)
{
- if (effect != BackgroundEffect.Remove) return frame;
+ if (effect == BackgroundEffect.None) return frame;
+ var mask = TrySegment(frame);
+ return mask == null ? frame : Render(frame, mask, effect, strength);
+ }
+ /// Runs the segmenter once; null (logged once) when it can't produce a usable mask.
+ public SegmentationMask? TrySegment(VideoFrame frame)
+ {
SegmentationMask? mask;
try
{
@@ -44,43 +62,200 @@ public sealed class BackgroundEffectProcessor
catch (Exception ex)
{
LogFailureOnce($"{ex.GetType().Name}: {ex.Message}");
- return frame;
+ return null;
}
- if (mask == null || mask.Values.Length < mask.Width * mask.Height || mask.Width < 1 || mask.Height < 1)
+ if (mask == null || mask.Width < 1 || mask.Height < 1 || mask.Values.Length < mask.Width * mask.Height)
{
LogFailureOnce("segmenter returned no usable mask");
- return frame;
+ return null;
}
-
- return ApplyRemove(frame, mask);
+ return mask;
}
- internal static VideoFrame ApplyRemove(VideoFrame frame, SegmentationMask mask)
+ public static VideoFrame Render(VideoFrame frame, SegmentationMask mask, BackgroundEffect effect, double strength)
+ {
+ var s = Math.Clamp(strength, 0, 1);
+ return effect switch
+ {
+ BackgroundEffect.Remove => ApplyRemove(frame, mask, s),
+ BackgroundEffect.Blur => ApplyBlur(frame, mask, s),
+ _ => frame,
+ };
+ }
+
+ internal static VideoFrame ApplyRemove(VideoFrame frame, SegmentationMask mask, double strength)
{
var src = frame.BgraPixels;
var dst = new byte[src.Length];
Buffer.BlockCopy(src, 0, dst, 0, src.Length);
- var w = frame.Width;
- var h = frame.Height;
+ // Edge feather: map the mask's [0.5 - w, 0.5 + w] band to alpha 0..1.
+ var w = (float)(0.05 + 0.45 * strength);
+ var lo = 0.5f - w;
+ var span = 2 * w;
+
+ var width = frame.Width;
+ var height = frame.Height;
var stride = frame.Stride;
- for (var y = 0; y < h; y++)
+ for (var y = 0; y < height; y++)
{
- var my = (y + 0.5f) * mask.Height / h - 0.5f;
+ var my = MaskCoord(y, height, mask.Height);
var row = y * stride;
- for (var x = 0; x < w; x++)
+ for (var x = 0; x < width; x++)
{
- var mx = (x + 0.5f) * mask.Width / w - 0.5f;
- var a = SampleBilinear(mask, mx, my);
- var alpha = (int)(a * src[row + x * 4 + 3] + 0.5f);
- dst[row + x * 4 + 3] = (byte)Math.Clamp(alpha, 0, 255);
+ var m = SampleBilinear(mask, MaskCoord(x, width, mask.Width), my);
+ var a = Math.Clamp((m - lo) / span, 0f, 1f);
+ var p = row + x * 4 + 3;
+ dst[p] = (byte)(a * src[p] + 0.5f);
}
}
- return new VideoFrame(w, h, dst) { Epoch = frame.Epoch, IsOpaque = false };
+ return new VideoFrame(width, height, dst) { Epoch = frame.Epoch, IsOpaque = false };
}
+ internal static VideoFrame ApplyBlur(VideoFrame frame, SegmentationMask mask, double strength)
+ {
+ var width = frame.Width;
+ var height = frame.Height;
+ var src = frame.BgraPixels;
+ var stride = frame.Stride;
+
+ // 1. Downscale by box-averaging BlurDownscale×BlurDownscale blocks (3 float channels).
+ var f = BlurDownscale;
+ var sw = (width + f - 1) / f;
+ var sh = (height + f - 1) / f;
+ var small = new float[sw * sh * 3];
+ for (var sy = 0; sy < sh; sy++)
+ {
+ var y0 = sy * f;
+ var y1 = Math.Min(height, y0 + f);
+ for (var sx = 0; sx < sw; sx++)
+ {
+ var x0 = sx * f;
+ var x1 = Math.Min(width, x0 + f);
+ float b = 0, g = 0, r = 0;
+ var n = 0;
+ for (var y = y0; y < y1; y++)
+ {
+ var p = y * stride + x0 * 4;
+ for (var x = x0; x < x1; x++, p += 4)
+ {
+ b += src[p]; g += src[p + 1]; r += src[p + 2];
+ n++;
+ }
+ }
+ var o = (sy * sw + sx) * 3;
+ small[o] = b / n; small[o + 1] = g / n; small[o + 2] = r / n;
+ }
+ }
+
+ // 2. Three separable box-blur passes ≈ a gaussian.
+ var radius = (int)Math.Round(MinBlurRadius + (MaxBlurRadius - MinBlurRadius) * strength);
+ var scratch = new float[small.Length];
+ for (var pass = 0; pass < 3; pass++)
+ {
+ BoxBlurHorizontal(small, scratch, sw, sh, radius);
+ BoxBlurVertical(scratch, small, sw, sh, radius);
+ }
+
+ // 3. Blend: person (mask) keeps the source pixel, background takes the upsampled blur.
+ var dst = new byte[src.Length];
+ for (var y = 0; y < height; y++)
+ {
+ var my = MaskCoord(y, height, mask.Height);
+ var by = (y + 0.5f) / f - 0.5f;
+ var row = y * stride;
+ for (var x = 0; x < width; x++)
+ {
+ var m = SampleBilinear(mask, MaskCoord(x, width, mask.Width), my);
+ var p = row + x * 4;
+ if (m >= 1f)
+ {
+ dst[p] = src[p]; dst[p + 1] = src[p + 1]; dst[p + 2] = src[p + 2]; dst[p + 3] = src[p + 3];
+ continue;
+ }
+ SampleSmall(small, sw, sh, (x + 0.5f) / f - 0.5f, by, out var bb, out var bg, out var br);
+ var k = 1f - m;
+ dst[p] = (byte)Math.Clamp(src[p] * m + bb * k + 0.5f, 0, 255);
+ dst[p + 1] = (byte)Math.Clamp(src[p + 1] * m + bg * k + 0.5f, 0, 255);
+ dst[p + 2] = (byte)Math.Clamp(src[p + 2] * m + br * k + 0.5f, 0, 255);
+ dst[p + 3] = src[p + 3];
+ }
+ }
+
+ return new VideoFrame(width, height, dst) { Epoch = frame.Epoch, IsOpaque = frame.IsOpaque };
+ }
+
+ private static void BoxBlurHorizontal(float[] input, float[] output, int w, int h, int r)
+ {
+ var norm = 1f / (2 * r + 1);
+ for (var y = 0; y < h; y++)
+ {
+ var row = y * w;
+ for (var c = 0; c < 3; c++)
+ {
+ float sum = 0;
+ for (var i = -r; i <= r; i++)
+ sum += input[(row + Math.Clamp(i, 0, w - 1)) * 3 + c];
+ for (var x = 0; x < w; x++)
+ {
+ output[(row + x) * 3 + c] = sum * norm;
+ var add = Math.Min(w - 1, x + r + 1);
+ var sub = Math.Max(0, x - r);
+ sum += input[(row + add) * 3 + c] - input[(row + sub) * 3 + c];
+ }
+ }
+ }
+ }
+
+ private static void BoxBlurVertical(float[] input, float[] output, int w, int h, int r)
+ {
+ var norm = 1f / (2 * r + 1);
+ for (var x = 0; x < w; x++)
+ {
+ for (var c = 0; c < 3; c++)
+ {
+ float sum = 0;
+ for (var i = -r; i <= r; i++)
+ sum += input[(Math.Clamp(i, 0, h - 1) * w + x) * 3 + c];
+ for (var y = 0; y < h; y++)
+ {
+ output[(y * w + x) * 3 + c] = sum * norm;
+ var add = Math.Min(h - 1, y + r + 1);
+ var sub = Math.Max(0, y - r);
+ sum += input[(add * w + x) * 3 + c] - input[(sub * w + x) * 3 + c];
+ }
+ }
+ }
+ }
+
+ private static void SampleSmall(float[] img, int w, int h, float x, float y, out float b, out float g, out float r)
+ {
+ var x0 = (int)MathF.Floor(x);
+ var y0 = (int)MathF.Floor(y);
+ var fx = x - x0;
+ var fy = y - y0;
+ var x1 = Math.Clamp(x0 + 1, 0, w - 1);
+ var y1 = Math.Clamp(y0 + 1, 0, h - 1);
+ x0 = Math.Clamp(x0, 0, w - 1);
+ y0 = Math.Clamp(y0, 0, h - 1);
+ var i00 = (y0 * w + x0) * 3;
+ var i01 = (y0 * w + x1) * 3;
+ var i10 = (y1 * w + x0) * 3;
+ var i11 = (y1 * w + x1) * 3;
+ var w00 = (1 - fx) * (1 - fy);
+ var w01 = fx * (1 - fy);
+ var w10 = (1 - fx) * fy;
+ var w11 = fx * fy;
+ b = img[i00] * w00 + img[i01] * w01 + img[i10] * w10 + img[i11] * w11;
+ g = img[i00 + 1] * w00 + img[i01 + 1] * w01 + img[i10 + 1] * w10 + img[i11 + 1] * w11;
+ r = img[i00 + 2] * w00 + img[i01 + 2] * w01 + img[i10 + 2] * w10 + img[i11 + 2] * w11;
+ }
+
+ private static float MaskCoord(int pixel, int frameSize, int maskSize)
+ => (pixel + 0.5f) * maskSize / frameSize - 0.5f;
+
private static float SampleBilinear(SegmentationMask mask, float x, float y)
{
var x0 = (int)MathF.Floor(x);
diff --git a/Services/index.md b/Services/index.md
index d9d92dc..6a3add1 100644
--- a/Services/index.md
+++ b/Services/index.md
@@ -18,7 +18,7 @@ External-facing logic: YouTube API, persistence. See
| `VideoFrame.cs` | Normalized CPU frame seam (`Width`/`Height`/tightly-packed BGRA `byte[]`) — the only pixel type the rest of the app knows about; future capture sources (screen, background-removed webcam) feed the same seam |
| `BackgroundEffects/IPersonSegmenter.cs` | **TASK 51 seam:** `Segment(VideoFrame) → SegmentationMask?` (`SegmentationMask(Width, Height, float[] Values)`, 0 = background, 1 = person); null = no mask → caller passes through. Tests inject fakes |
| `BackgroundEffects/OnnxPersonSegmenter.cs` | **TASK 51 default segmenter:** MediaPipe Selfie Segmenter (landscape, Apache-2.0) as an embedded ONNX resource, run by onnxruntime on the CPU (2 intra-op threads); frame stretched to 256×144 RGB/255 NHWC; lazy session; never throws (fail → log once, null forever) |
-| `BackgroundEffects/BackgroundEffectProcessor.cs` | **TASK 51 pure pixel stage:** `Apply(frame, BackgroundEffect)` — None = same instance, Remove = copy with alpha = bilinear mask × source alpha (`IsOpaque=false`), Blur = passthrough until slice 2; no mask/exception → passthrough + one log. Not wired into the camera path yet |
+| `BackgroundEffects/BackgroundEffectProcessor.cs` | **TASK 51 pure pixel stage:** `Apply(frame, BackgroundEffect)` — None = same instance, Remove = copy with alpha = feathered mask × source alpha (`IsOpaque=false`), Blur = background blurred at ¼ res (3 box passes) and mask-blended, person untouched; strength 0..1; `TrySegment` once + `Render` per effect; no mask/exception → passthrough + one log |
| `CameraDeviceInfo.cs` | `(Id, DisplayName)` for a physical capture device |
| `ICameraEnumerator.cs` | `GetCamerasAsync()` — seam so the picker/`CameraManager` never touch WinRT (tests inject fakes) |
| `ICameraFrameSource.cs` | `StartAsync`/`StopAsync`/`FrameAvailable(VideoFrame)` + **`SourceFailed(string)`** — seam for a running capture source (tests inject fakes) |
diff --git a/TASKS/task-51-webcam-bg-effects.md b/TASKS/task-51-webcam-bg-effects.md
index 436b4bc..19ce9d6 100644
--- a/TASKS/task-51-webcam-bg-effects.md
+++ b/TASKS/task-51-webcam-bg-effects.md
@@ -57,7 +57,8 @@ delivery shape changed:
obs-backgroundremoval code (GPL-3.0) — referenced for approach only (ONNX Runtime, CPU, 2 threads).
- ✅ `Microsoft.ML.OnnxRuntime` 1.31.0, CPU EP (`OnnxPersonSegmenter`). Failing load/run →
logs once, returns null, caller passes through. DirectML still optional/later.
-- ☐ Blur = **separable in-app blur** (box/gaussian) over the masked background region — slice 2.
+- ✅ **Blur (slice 2, 2026-10-09):** background box-averaged to ¼ res, 3 separable box-blur passes
+ (≈ gaussian, radius by strength), mask-blended back; person pixels untouched (`BackgroundEffectProcessor.ApplyBlur`).
- ✅ Remove = alpha = mask outside the person (`BackgroundEffectProcessor`), `IsOpaque = false`.
The compositor already alpha-blends per element — no compositor change expected (confirm in
the hookup slice).
@@ -87,7 +88,7 @@ delivery shape changed:
- ☐ Assert the **processed frame reaches both consumers** (preview bitmap + compositor frame).
- ✅ `None` = same-instance passthrough of the raw frame (`BackgroundEffectEngineTests`).
- ✅ `Remove` = alpha 0 outside the person, RGB untouched, non-opaque (`BackgroundEffectEngineTests`);
- ☐ `Blur` = background region blurred, person sharp.
+ ✅ `Blur` = background smoothed, person pixel-identical (`BackgroundEffectEngineTests`).
- ✅ **Bundled-model contract:** the embedded ONNX model through the C# preprocessing reproduces
MediaPipe's reference mask on a fixture (≥99.5% agreement). This is a fixed, deterministic
contract check of the shipped bytes — not a camera/lighting quality test.
@@ -102,7 +103,7 @@ delivery shape changed:
## 4. Sequencing
- ✅ Separate work unit after the zoom build (zoom shipped 2026-10-08).
-- Slice order: 1 engine ✅ → 2 Blur → 3 config + persistence → 4 camera-path hookup (one
+- Slice order: 1 engine ✅ → 2 Blur ✅ → 3 config + persistence → 4 camera-path hookup (one
processing point, both consumers) + perf check → 5 properties-panel UI.
## Related (not duplicated here)
diff --git a/ai.md b/ai.md
index e0cd935..cf04c01 100644
--- a/ai.md
+++ b/ai.md
@@ -634,9 +634,13 @@ This replaces the old five-seeder cluster (`Seed{Starting,Brb,Ending,Chat}Backgr
resource; **never throws** — a load/run failure logs once and returns null forever after);
`BackgroundEffectProcessor.Apply(frame, BackgroundEffect)` — `None` = same instance (true
passthrough), `Remove` = copy with alpha = bilinear-upscaled mask × source alpha, RGB untouched,
- `IsOpaque = false` (the compositor's straight-alpha blend shows the layers below); `Blur` passes
- through until slice 2. No mask / segmenter exception → passthrough + one log line, never a dropped
- layer.
+ `IsOpaque = false` (the compositor's straight-alpha blend shows the layers below), strength =
+ edge feather (mask band 0.5±(0.05+0.45·s) → alpha); `Blur` (slice 2) = background downscaled 4×
+ by box average, 3 separable box-blur passes (radius 1→12 small px by strength ≈ gaussian, up to
+ ~48 full px), bilinear-upsampled and blended by the mask — person pixels (mask = 1) are copied
+ untouched, opacity preserved. The mask is computed once per frame (`TrySegment`) and rendered per
+ effect (`Render`), so several variants share one inference. No mask / segmenter exception →
+ passthrough + one log line, never a dropped layer.
- **Not wired yet:** nothing calls the processor — no config, no UI, no `CameraManager` hookup. The
next slices add Blur, the per-webcam `BackgroundEffect` config + persistence, then the single
processing point on the camera path (both preview and compositor consume the processed frame).
diff --git a/ytLive.Tests/BackgroundEffectEngineTests.cs b/ytLive.Tests/BackgroundEffectEngineTests.cs
index 48264bd..1a09c38 100644
--- a/ytLive.Tests/BackgroundEffectEngineTests.cs
+++ b/ytLive.Tests/BackgroundEffectEngineTests.cs
@@ -99,6 +99,54 @@ public class BackgroundEffectEngineTests
Assert.InRange(100.0 * person / total, 45, 60); // reference: 52.5% person
}
+ /// 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.
+ [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]);
+ }
+ }
+ }
+
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);