diff --git a/HANDOFF.md b/HANDOFF.md
index 644d594..f7f6321 100644
--- a/HANDOFF.md
+++ b/HANDOFF.md
@@ -1,4 +1,4 @@
-# HANDOFF — 2026-10-09 (TASK 51 quality pass: jitter FIXED; blur-heavy is the next unit)
+# HANDOFF — 2026-10-09 (TASK 51 quality pass COMPLETE: jitter + blur-heavy both fixed)
## Branch
- main (no feature branch). Pushed through `5685c5d` (TRAX toggle); everything after is local,
@@ -17,31 +17,25 @@
the not-mine files WILL flag. Pass them to the script as declared-known (checksums unchanged since
`5685c5d`) or let the script's output be reconciled before committing.
-## What just happened — temporal jitter fix
-- Creator check: bg Remove is "jittery", Blur is "heavy". User picked **jitter first**.
-- Root cause: `OnnxPersonSegmenter` classifies each frame independently (256×144 mask, bilinear
- upsample) → zero temporal coherence → edge flutter. There is no temporal smoothing upstream.
-- **Fix: `MaskSmoother`** — per-session blend of each raw mask with the previous smoothed one,
- 0.75×new + 0.25×previous (`CurrentWeight`, internal). Wired at the ONE production point
- (`CameraManager.RunEffectWorker` → `session.Smoother.Smooth(TrySegment(source))`); state on
- `CameraSession`, NOT the segmenter (segmenter is shared app-wide and contract-pinned to raw output).
- Null mask resets history; first frame/size-change passthrough; never aliases the caller's array; two
- ping-pong buffers, no per-frame LOH alloc. Weights: OBS obs-backgroundremoval 0.85 default test, Jitsi
- jitsi/jitsi-meet#17080 0.7/0.3 best (0.5/0.5 smears on motion).
-- **Test:** `CameraManagerTests.Temporal_Smoothing_Damps_Segmentation_Jitter_Across_Frames` — PASSED.
- A `SteppingMaskSegmenter` returns fresh masks per call: `{1,1,0,0}` then `{1,0.2,0,0}`; 16×4 frame,
- Remove 0.5. Asserts x=6: frame1 = 255 (passthrough), frame2 smoothed = **58**, raw-unsmoothed = 0;
- person core x=1 = 255, background x=14 = 0. ⚠️ Gotcha: the worker's latest-frame-drop guard treats a
- re-queued IDENTICAL frame reference as "no new frame" — pump frame2 as a NEW `VideoFrame` instance.
-- `Assert.Equal(255, first[...])`/`58`/`0` predictions were hand-verified; other pixels differ by
- float rounding (e.g. x=5 Python 34 vs C# 36) — only assert x=6/x=1/x=14.
+## What just happened — two commits
+1. **Jitter fix (`3057069`) — `MaskSmoother`**: per-session blend 0.75×new + 0.25×previous at the
+ ONE worker (`CameraManager.RunEffectWorker` → `session.Smoother.Smooth(TrySegment(source))`).
+ State on `CameraSession`, NOT the segmenter (shared app-wide, contract-pinned to raw output). Null
+ resets history; first frame passes through; never aliases caller's array; ping-pong buffers (no
+ per-frame LOH alloc). Weights: OBS obs-backgroundremoval 0.85 default, Jitsi 0.7/0.3 best
+ (0.5/0.5 smears). Test `Temporal_Smoothing_Damps_Segmentation_Jitter_Across_Frames`: x=6 → first
+ 255 / smoothed 58 / raw-unsmoothed 0. ⚠️ Gotcha: worker guards a re-queued identical frame
+ reference — pump frame2 as a NEW instance.
+2. **Blur "heavy" fix (this commit) — quadratic radius**: `BlurRadius(strength)` = 1 + 5·s²,
+ `MaxBlurRadius` 12→6 small px (≈ 24 full px max). Default 0.5 = 2 small px (was 6 under the old
+ linear curve) — ~3× lighter spread. Rationale: StreamFX defaults Box blur Size to 5, author stops
+ downscaled blurs ~8px. Test `Blur_default_strength_is_gentle_and_full_strength_is_strong` pins the
+ mapping and step-edge behaviour (reach 9px default vs 26px max). Point-source probes vanish below
+ the 0.5 rounding (3 box passes dilute a spike) — use a step edge, not a spike, to measure blur.
## Next step
-1. Run `./scripts/verify.sh` with the declared file list; reconcile the not-mine dirty files with
- scope-check; commit the unit (message `…(Unit: bg-removal jitter fix)` + OBS/Jitsi citations).
-2. Next unit = **Blur "heavy"** — soften the radius curve in `BackgroundEffectProcessor.ApplyBlur`
- (`MinBlurRadius=1 / MaxBlurRadius=12` at ¼ res ≈ 26–50 full px; strength 0.5 → 6 small px). The
- creator needs a retry pass on the real camera after both.
+- Creator re-runs the app: webcam → Layers properties → Background → Remove / Blur + Strength, and
+ reports whether the jitter is gone and the blur feels right at the slider's default and full range.
## Also still open
- **Vertical-format regression (STALE, not fixed):** 1080p60 vertical selection no longer shrinks the
@@ -53,7 +47,7 @@
- **Vertical recording file closed:** the recording itself is 1080×1920 60fps correctly; the
"whitespace" was the app's preview UI, not the file. Audio loudness (`ebur128`) for the private video
was never run and the thread is closed unless the user reopens it.
-- Blur-heaviness = next unit (above). Yellow-border regression = stale thread.
+- Blur-heaviness = fixed (above). Yellow-border regression = stale thread.
- ⛔ Sudo password rotation pending (user; then update both password lines in `CREDENTIALS.md`).
- TASK 56 secret scan before `gramps/LlamaCasty` goes public. TASK 55 disk hygiene queued.
diff --git a/Services/BackgroundEffects/BackgroundEffectProcessor.cs b/Services/BackgroundEffects/BackgroundEffectProcessor.cs
index 09e77d6..1a9355d 100644
--- a/Services/BackgroundEffects/BackgroundEffectProcessor.cs
+++ b/Services/BackgroundEffects/BackgroundEffectProcessor.cs
@@ -32,9 +32,12 @@ public sealed class BackgroundEffectProcessor
/// 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.
+ /// Box-blur radius (in downscaled pixels) at strength 0 and 1. The maximum was halved
+ /// from 12 (2026-10-09): the creator found full range too heavy — the old max spread ≈48 full
+ /// px. The established tools blur far less: StreamFX defaults its Box blur size to 5 and its
+ /// author stops downscale-based blurs around 8 px.
internal const int MinBlurRadius = 1;
- internal const int MaxBlurRadius = 12;
+ internal const int MaxBlurRadius = 6;
private readonly IPersonSegmenter _segmenter;
private bool _loggedFailure;
@@ -161,7 +164,7 @@ public sealed class BackgroundEffectProcessor
});
// 2. Three separable box-blur passes ≈ a gaussian.
- var radius = (int)Math.Round(MinBlurRadius + (MaxBlurRadius - MinBlurRadius) * strength);
+ var radius = BlurRadius(strength);
var scratch = new float[small.Length];
for (var pass = 0; pass < 3; pass++)
{
@@ -212,6 +215,15 @@ public sealed class BackgroundEffectProcessor
return new VideoFrame(width, height, dst) { Epoch = frame.Epoch, IsOpaque = frame.IsOpaque };
}
+ /// Blur radius in downscaled pixels for a strength. Quadratic (s²), not linear, so the
+ /// default 0.5 stays gentle (2 small px ≈ ~8–10 full px of perceived spread, ~3× lighter than
+ /// the old linear 6) while only the top of the slider approaches the full range.
+ internal static int BlurRadius(double strength)
+ {
+ var s = Math.Clamp(strength, 0, 1);
+ return (int)Math.Round(MinBlurRadius + (MaxBlurRadius - MinBlurRadius) * s * s);
+ }
+
/// Row-parallel pixel loops, capped so the effect worker can't starve the compositor
/// and encoder threads.
private static readonly ParallelOptions RowParallelism = new()
diff --git a/Services/index.md b/Services/index.md
index 3ddeb6f..cc74c2f 100644
--- a/Services/index.md
+++ b/Services/index.md
@@ -20,7 +20,7 @@ External-facing logic: YouTube API, persistence. See
| `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/WebcamEffectKey.cs` | **TASK 51:** `(Effect, Strength)` variant key, strength rounded to 0.01; `None` normalizes to strength 0 (the raw path) |
-| `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 |
+| `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; blur radius is **quadratic** via `BlurRadius(strength)` — 1→6 small px (default 0.5 = 2, was a linear 1→12 ≈ 48 full px; the "blur heavy" fix 2026-10-09); `TrySegment` once + `Render` per effect; no mask/exception → passthrough + one log |
| `BackgroundEffects/MaskSmoother.cs` | **TASK 51 jitter fix (2026-10-09):** temporally blends each raw mask with the previous smoothed one — `CurrentWeight` 0.75 × new + 0.25 × previous — so a per-frame segmentation verdict that wavers at an edge doesn't flutter the cut-out. Null input resets history; first frame/size change pass through; the returned mask never aliases the caller's array and the two buffers ping-pong (no per-frame LOH alloc: a 256×144 mask is 147 KB). State is **per camera session**, not in the segmenter (which is shared app-wide and contract-pinned to raw output). Weights from the OBS/Jitsi consensus — see `ai.md` "Webcam background effects" |
| `CameraDeviceInfo.cs` | `(Id, DisplayName)` for a physical capture device |
| `ICameraEnumerator.cs` | `GetCamerasAsync()` — seam so the picker/`CameraManager` never touch WinRT (tests inject fakes) |
diff --git a/TASKS.md b/TASKS.md
index c99f640..6129040 100644
--- a/TASKS.md
+++ b/TASKS.md
@@ -66,7 +66,7 @@
| 48 | Distribution & packaging: **MSIX + Store IAP** | 🔶 In progress — **✅ route decided 2026-09-27 (Store MSIX + Store IAP)**; **item 1 (bundle ffmpeg) SHIPPED 2026-09-28** | [`TASKS/task-48-distribution-msix.md`](TASKS/task-48-distribution-msix.md) |
| 49 | Chat profanity filter (local, opt-in, non-persistent, user word list) | ☐ Queued (2026-09-27) | [`TASKS/task-49-chat-profanity-filter.md`](TASKS/task-49-chat-profanity-filter.md) |
| 50 | One-button "Publish this video" (resumable upload of the local recording) | ☐ Queued (2026-09-28) | [`TASKS/task-50-publish-recording.md`](TASKS/task-50-publish-recording.md) |
-| 51 | **Webcam background effects: Remove + Blur** | ✅ **Built 2026-10-09** (v1 requirement) — bundled MediaPipe Selfie Segmenter → ONNX (verified vs Google's runtime), Remove + Blur with strength, per-scene config persisted, one processing point in CameraManager, radio-pill UI in the webcam's Layers properties. Creator check 2026-10-09: **Remove jitter FIXED** (temporal mask smoothing 0.75/0.25, OBS/Jitsi-derived); **Blur "heavy" = next work unit** (radius curve) | [`TASKS/task-51-webcam-bg-effects.md`](TASKS/task-51-webcam-bg-effects.md) |
+| 51 | **Webcam background effects: Remove + Blur** | ✅ **Built 2026-10-09** (v1 requirement) — bundled MediaPipe Selfie Segmenter → ONNX (verified vs Google's runtime), Remove + Blur with strength, per-scene config persisted, one processing point in CameraManager, radio-pill UI in the webcam's Layers properties. Creator check 2026-10-09: **Remove jitter FIXED** (temporal mask smoothing 0.75/0.25, OBS/Jitsi-derived) and **Blur "heavy" FIXED** (quadratic radius 1→6 small px, default 0.5 = 2, StreamFX-consensus); recheck on the real camera pending | [`TASKS/task-51-webcam-bg-effects.md`](TASKS/task-51-webcam-bg-effects.md) |
| 52 | **Zoom box (anchored 2× magnifier)** | ✅ Done — shipped 2026-10-08 (3 creator corrections during the build: loupe → anchored capture over the full composite, drawn last → never shows the dim shading) | [`TASKS/task-52-zoom-box.md`](TASKS/task-52-zoom-box.md) |
| 53 | **Output-format verification: Shorts/TikTok geometry (9:16)** | ☐ Queued (2026-10-08) — output formats never tested end-to-end; pin + prove the vertical geometry | [`TASKS/task-53-output-formats.md`](TASKS/task-53-output-formats.md) |
| 54 | **Marcom round: free-forever positioning + merch storefront** | 🔶 In progress — llamacasty.com copy live 2026-10-08 (pricing stripped); merch POD + app About links queued | [`TASKS/task-54-marcom-free-merch.md`](TASKS/task-54-marcom-free-merch.md) |
diff --git a/TASKS/task-51-webcam-bg-effects.md b/TASKS/task-51-webcam-bg-effects.md
index 57bad12..44d9b56 100644
--- a/TASKS/task-51-webcam-bg-effects.md
+++ b/TASKS/task-51-webcam-bg-effects.md
@@ -5,10 +5,11 @@
> This **reverses** the 2026-10-05 "background removal: out of product" exclusion (TASK 3 item 19).
**Status:** ✅ **Built 2026-10-09 — all five slices shipped** (engine, Blur, config + persistence,
-camera hookup, UI). **Creator's real-camera check done 2026-10-09:** 🎯 **Remove "jittery" — FIXED**
-(temporal mask smoothing, `MaskSmoother` — 0.75×new + 0.25×previous momentum, weights from the
-OBS/Jitsi consensus); 🔶 **Blur "heavy" — the remaining follow-up** (next work unit, on the radius
-curve in `BackgroundEffectProcessor.ApplyBlur`; documented below).
+camera hookup, UI). **Creator's real-camera check done 2026-10-09 and both findings addressed:**
+🎯 **Remove "jittery" — FIXED** (temporal mask smoothing, `MaskSmoother` — 0.75×new + 0.25×previous
+momentum, weights from the OBS/Jitsi consensus); 🎯 **Blur "heavy" — FIXED** (quadratic radius curve,
+max 12→6 small px, default 0.5 = 2 — StreamFX-consensus scale). **Pending: creator's recheck of both
+on a real camera.**
**Goal:** per-webcam **Background Remove** and **Background Blur** on the live webcam layer,
selected per element in its properties panel, composited into stream + recording like any
@@ -123,10 +124,15 @@ delivery shape changed:
resets history; first frame passes through; no per-frame allocation (two ping-pong buffers — a
256×144 mask is 147 KB > the 85 KB LOH). Integration test: `CameraManagerTests.Temporal_Smoothing_…`
(two consecutive masks disagree at an edge → second frame keeps partial alpha where raw would cut).
-- 🔶 **Finding: "blur is heavy"** — strength 0.5 ≈ radius 6 downscaled px ≈ **26 full-res px**, max
- ≈ 50 px (1+11·s at ¼ res, 3 box passes). **Next work unit:** soften the radius curve/range
- (e.g. lower the strength→radius mapping, and/or a shorter maximum) — see
- `BackgroundEffectProcessor` `MinBlurRadius`/`MaxBlurRadius`/`BlurDownscale`.
+- 🎯 **Finding: "blur is heavy"** — the OLD curve was linear 1→12 downscaled px (≈ 26–50 full-res px),
+ far past any moderate background blur. **Fix shipped: quadratic radius** —
+ `BackgroundEffectProcessor.BlurRadius(strength)` = 1 + 5·s²: default 0.5 = 2 small px (≈ a third of
+ the old default's spread), max = 6 small px (≈ 24 full px). Why these numbers: StreamFX defaults its
+ Box blur Size to 5 and its author stops downscale-based blurs around 8 px (cited). Integration test
+ `BackgroundEffectEngineTests.Blur_default_strength_is_gentle_and_full_strength_is_strong` pins the
+ mapping (BlurRadius(0)=1, 0.5→2, 1→6) and the step-edge behaviour: default smears light ~9px into
+ the dark half vs ~26px at full strength. The existing slice-2 blur test (person sharp + checkerboard
+ smoothed) passes unchanged.
- ✅ `startup.log` perf line present on the real box ("background effects avg … ms/frame").
## Related (not duplicated here)
diff --git a/ai.md b/ai.md
index 1fca885..c30c89a 100644
--- a/ai.md
+++ b/ai.md
@@ -636,11 +636,14 @@ This replaces the old five-seeder cluster (`Seed{Starting,Brb,Ending,Chat}Backgr
passthrough), `Remove` = copy with alpha = bilinear-upscaled mask × source alpha, RGB untouched,
`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.
+ by box average, 3 separable box-blur passes and bilinear-upsampled, blended by the mask — person
+ pixels (mask = 1) are copied
+ untouched, opacity preserved. **Blur radius is quadratic** (`BlurRadius(strength)`: 1→6 small px,
+ default 0.5 = 2 — the "blur heavy" fix 2026-10-09; the old LINEAR 1→12 small px ≈ 48 full px was
+ far beyond what the established tools use: StreamFX defaults its Box blur Size to 5 and its author
+ stops downscale-based blurs around 8 px). 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.
- **Config (slice 3):** per scene, on `WebcamSceneConfig` — `BackgroundEffect` + `BackgroundStrength`
(0..1, default 0.5), persisted as two additive guarded columns on `WebcamSceneConfig`
(`BackgroundEffect TEXT DEFAULT 'None'`, `BackgroundStrength REAL DEFAULT 0.5`, no version bump —
@@ -668,7 +671,8 @@ which also `ScheduleSave`s). Per raw frame a background worker (latest-frame-dro
come from the established tools, not a guess: OBS's obs-backgroundremoval smoke-tests
`temporal_smooth_factor` at its 0.85 default, Jitsi's webcam background filter (jitsi/jitsi-meet#17080)
found 0.7/0.3 transformed best — a midpoint that still reacts to real movement; 0.5/0.5 leaves a
- smearing edge ghost. Blur remains per-frame ("blur is heavy" = next work unit, on the radius curve).
+ smearing edge ghost. The blur "heavy" follow-up was fixed the same day: quadratic radius, 1→6
+ small px (see the `Blur` bullet above).
- **Perf (synthetic 1280×720, Debug build):** segment 1.6 ms, Remove 3.3 ms, Blur 17.5 ms per frame
(Release is several × faster). Pixel loops are row-parallel, capped at min(4, cores/2); the
bilinear upsampling is separable (horizontal rows precomputed per mask/small row, one vertical lerp
@@ -689,6 +693,9 @@ which also `ScheduleSave`s). Per raw frame a background worker (latest-frame-dro
— the ONE integration test for the jitter fix (a `SteppingMaskSegmenter` whose two consecutive
masks disagree at an edge; asserts the second frame keeps a partial alpha where the raw mask would
have cut to 0: x=6 → 58 upstairs of the raw 0, person core 255, background 0).
+ `Blur_default_strength_is_gentle_and_full_strength_is_strong` pins the quadratic radius mapping
+ (`BlurRadius(0.5)` = 2, `BlurRadius(1)` = 6) and the behaviour at a step edge: default smears
+ light ~9px into the dark half vs ~26px at full strength.
### Frame cadence — how a dropped frame became measurable (UNIT 0, 2026-10-05)
diff --git a/ytLive.Tests/BackgroundEffectEngineTests.cs b/ytLive.Tests/BackgroundEffectEngineTests.cs
index 1a09c38..79fc5a5 100644
--- a/ytLive.Tests/BackgroundEffectEngineTests.cs
+++ b/ytLive.Tests/BackgroundEffectEngineTests.cs
@@ -147,6 +147,67 @@ public class BackgroundEffectEngineTests
}
}
+ /// 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.
+ [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");
+ }
+
+ /// 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.
+ 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);