|
| 1 | +# Portrait background blur — evolving a hot function behind a hard quality gate |
| 2 | + |
| 3 | +Optimise the per-frame background blur of a video pipeline. The score is the |
| 4 | +**speedup**, but fidelity is a **hard gate**: a candidate that is fast and wrong |
| 5 | +scores exactly zero. |
| 6 | + |
| 7 | +This is the "optimise a real hot function" pattern. The interesting part is not the |
| 8 | +LLM — it is the **evaluator**. Getting the gate right *is* the job. |
| 9 | + |
| 10 | +``` |
| 11 | +speedup = baseline_time / candidate_time |
| 12 | +
|
| 13 | +reject if mean SSIM < 0.98 (overall fidelity) |
| 14 | + or worst-frame SSIM < 0.95 (no bad frames) |
| 15 | + or worst-REGION SSIM < 0.90 (no bad regions) |
| 16 | +otherwise score = speedup |
| 17 | +``` |
| 18 | + |
| 19 | +Everything is deterministic and self-contained: the "video" is synthesised from a |
| 20 | +fixed seed, so there is no webcam, no GPU, no ML model and no dataset to download. |
| 21 | +`numpy` is the only dependency. |
| 22 | + |
| 23 | +## The task |
| 24 | + |
| 25 | +For each frame, blur the background and composite the sharp person back on top, |
| 26 | +using a supplied segmentation mask: |
| 27 | + |
| 28 | +```python |
| 29 | +blurred = gaussian_blur(frame, sigma) |
| 30 | +out = mask * frame + (1 - mask) * blurred |
| 31 | +``` |
| 32 | + |
| 33 | +The seed (`initial_program.py`) is **correct but slow**: it convolves with the full |
| 34 | +2D Gaussian directly — O(k²) passes per frame. It is the honest implementation you |
| 35 | +would write first, and it is the thing to beat. |
| 36 | + |
| 37 | +## Run it |
| 38 | + |
| 39 | +```bash |
| 40 | +export OPENAI_API_KEY=sk-or-... # your OpenRouter key |
| 41 | + |
| 42 | +python openevolve-run.py \ |
| 43 | + examples/background_blur/initial_program.py \ |
| 44 | + examples/background_blur/evaluator.py \ |
| 45 | + --config examples/background_blur/config.yaml \ |
| 46 | + --iterations 100 |
| 47 | +``` |
| 48 | + |
| 49 | +`config.yaml` uses OpenRouter with a cheap model (`google/gemini-2.5-flash-lite`); the |
| 50 | +key is read from `OPENAI_API_KEY`. |
| 51 | +Any OpenAI-compatible endpoint works — point `api_base` at a local optillm server to |
| 52 | +run with no hosted API and no key at all. |
| 53 | + |
| 54 | +## Results |
| 55 | + |
| 56 | +100 iterations. Timings are best-of-N on an idle machine (see the timing note below — |
| 57 | +this matters more than you would think). |
| 58 | + |
| 59 | +| | ms/frame | speedup | mean SSIM | worst-region | |
| 60 | +|---|---:|---:|---:|---:| |
| 61 | +| seed — naive O(k²) convolution | 33.4 | 1.0x | 1.0000 | 1.0000 | |
| 62 | +| expert — hand-written separable + fp32 | 1.46 | 22.8x | 1.0000 | 1.0000 | |
| 63 | +| **evolved (OpenEvolve)** | **0.54** | **62x** | 0.9915 | 0.9723 | |
| 64 | + |
| 65 | +**Read those two speedup columns carefully.** 62x is against a baseline that was |
| 66 | +*written to be bad on purpose*, so it flatters the result. The number that actually |
| 67 | +means something is the comparison against a competent implementation: |
| 68 | + |
| 69 | +> **The evolved solution is 2.6x faster than a hand-written separable+fp32 blur** — |
| 70 | +> and it gets there by spending part of the fidelity budget the gate allows |
| 71 | +> (SSIM 0.9915 vs the expert's exact 1.0000). It is a real win, not a free one. |
| 72 | +
|
| 73 | +28 of the 100 candidates were **rejected outright by the quality gate**. The gate is |
| 74 | +not decoration. |
| 75 | + |
| 76 | +### What it discovered |
| 77 | + |
| 78 | +The winning program stacks five distinct optimisations, one of which (blurring at |
| 79 | +reduced resolution) is the same class of trick reported for this problem elsewhere: |
| 80 | + |
| 81 | +```python |
| 82 | +# 1. separable Gaussian: two 1D passes instead of k^2 |
| 83 | +# 2. blur at HALF resolution, then upsample (quarter the pixels) |
| 84 | +downsample = sigma >= 1.0 |
| 85 | +sigma_eff = sigma / 2.0 # rescale sigma for the small domain |
| 86 | +low = frames_arr[:, ::2, ::2, :] |
| 87 | +blurred_low = _separable_blur_batch(low, kernel) |
| 88 | +blurred = np.repeat(np.repeat(blurred_low, 2, axis=1), 2, axis=2) |
| 89 | + |
| 90 | +# 3. float32 rather than float64 (halves memory traffic) |
| 91 | +# 4. BATCH the whole sequence into (N, H, W, C) and blur it in one vectorised pass |
| 92 | +frames_arr = np.stack(frames).astype(np.float32) |
| 93 | + |
| 94 | +# 5. lerp composite: one multiply fewer than m*f + (1-m)*b |
| 95 | +composited = blurred + masks_arr[..., None] * (frames_arr - blurred) |
| 96 | +``` |
| 97 | + |
| 98 | +Nobody told it to batch across frames. It is search, not magic — but it is real search. |
| 99 | + |
| 100 | +## Attack your own evaluator first |
| 101 | + |
| 102 | +Before spending a single token, prove the cheats lose: |
| 103 | + |
| 104 | +```bash |
| 105 | +python examples/background_blur/test_gaming.py |
| 106 | +``` |
| 107 | + |
| 108 | +Each test is a candidate that is **fast but wrong**, and must score 0: |
| 109 | + |
| 110 | +| cheat | why it is tempting | |
| 111 | +|---|---| |
| 112 | +| return the input untouched | infinitely fast | |
| 113 | +| blur everything, ignore the mask | skips compositing | |
| 114 | +| under-blur with a tiny kernel | far cheaper than the real sigma | |
| 115 | +| blur frame 0's background, reuse it forever | ~50x faster | |
| 116 | + |
| 117 | +...plus one honest optimisation (separable convolution) that **must** be accepted. |
| 118 | + |
| 119 | +### The gate we would have shipped was broken |
| 120 | + |
| 121 | +The first version of this evaluator used the obvious gate — mean SSIM ≥ 0.98 and |
| 122 | +worst-frame SSIM ≥ 0.95. The **stale-background** cheat sailed straight through and |
| 123 | +scored **47x**: |
| 124 | + |
| 125 | +| candidate | mean | worst-frame | **worst-region** | |
| 126 | +|---|---:|---:|---:| |
| 127 | +| separable (exact) | 1.0000 | 1.0000 | 1.0000 | |
| 128 | +| **CHEAT: stale background** | 0.9871 | **0.9806** | **0.7445** | |
| 129 | +| half-res blur (legitimate) | 0.9915 | 0.9912 | 0.9723 | |
| 130 | +| 3× box blur (legitimate) | 0.9949 | 0.9944 | 0.9901 | |
| 131 | + |
| 132 | +Reusing one blurred background leaves a **person-shaped ghost** where the person used |
| 133 | +to be. A human sees it instantly — but it damages a small patch, and whole-image SSIM |
| 134 | +averages it away. Both frame-level gates pass. |
| 135 | + |
| 136 | +The fix is to grade the **worst 16×16 block**, not the whole frame. The ghost region |
| 137 | +scores 0.74 while genuine approximations stay above 0.97, so `worst_region ≥ 0.90` |
| 138 | +separates them cleanly. |
| 139 | + |
| 140 | +**The lesson generalises:** an aggregate metric hides localised damage. If your quality |
| 141 | +bar is an average, the search will find the thing your average cannot see. Write the |
| 142 | +cheats yourself and make sure they lose. |
| 143 | + |
| 144 | +## Timing as fitness is its own trap |
| 145 | + |
| 146 | +`parallel_evaluations: 1` is necessary — concurrent evaluations contend for CPU and |
| 147 | +corrupt the measurement — but it is **not sufficient**. |
| 148 | + |
| 149 | +The first version of this evaluator measured the baseline **once**, cached it, and |
| 150 | +compared every later candidate against it. That one measurement happened while the |
| 151 | +machine was busy, so the baseline was inflated (95.6 ms/frame vs a true 33.4) — and |
| 152 | +therefore *every speedup reported during the run was inflated too*. The winner was |
| 153 | +reported at **82x**; it is really **62x**. |
| 154 | + |
| 155 | +The fix (`_benchmark()` in `evaluator.py`): |
| 156 | + |
| 157 | +- measure baseline and candidate **interleaved, in the same call**, so slow drift |
| 158 | + cancels instead of accumulating into the ratio; |
| 159 | +- **warm up** before timing; |
| 160 | +- take the **minimum** of N runs — background load can only ever *add* time, so the |
| 161 | + minimum is the best estimate of the true cost, for both sides. |
| 162 | + |
| 163 | +If your fitness is a wall-clock number, re-measure your final result on an idle |
| 164 | +machine before you believe it. |
| 165 | + |
| 166 | +## Why OpenEvolve fits this problem |
| 167 | + |
| 168 | +**Cascade evaluation — don't benchmark garbage.** Timing is the expensive part, so it |
| 169 | +is the last thing we do: |
| 170 | + |
| 171 | +``` |
| 172 | +stage 1 cheap smoke test (2 frames: shape, finite, did you blur at all?) |
| 173 | +stage 2 the quality gate (all frames: SSIM vs the reference) |
| 174 | +stage 3 the benchmark (only for candidates that earned it) |
| 175 | +``` |
| 176 | + |
| 177 | +**Artifacts — tell the model *why* it failed.** A scalar score says "0". OpenEvolve's |
| 178 | +artifact side-channel hands the next prompt the actual reason: |
| 179 | + |
| 180 | +> QUALITY GATE FAILED: mean SSIM 0.9871 (needs >= 0.98), worst-frame SSIM 0.9806 on |
| 181 | +> frame 6, worst-REGION SSIM 0.7445 (needs >= 0.90). ... do not reuse a stale |
| 182 | +> background (it leaves a person-shaped ghost that wrecks one region while barely |
| 183 | +> moving the frame average). |
| 184 | +
|
| 185 | +**MAP-Elites — keep rival strategies alive.** The grid is `(complexity, ssim)`, so |
| 186 | +"exact and fast" (separable, SSIM 1.0) and "approximate and faster" (half-res, SSIM |
| 187 | +0.99) both survive instead of the population collapsing onto whichever appeared first. |
| 188 | + |
| 189 | +## Files |
| 190 | + |
| 191 | +| file | what it is | |
| 192 | +|---|---| |
| 193 | +| `initial_program.py` | the seed — correct, slow, `EVOLVE-BLOCK` markers | |
| 194 | +| `evaluator.py` | 3-stage cascade, the hard gate, interleaved timing, artifacts | |
| 195 | +| `fixtures.py` | deterministic scene, trusted reference, SSIM + worst-region SSIM | |
| 196 | +| `test_gaming.py` | adversarial tests — the cheats must lose | |
| 197 | +| `config.yaml` | OpenRouter endpoint, MAP-Elites grid, `parallel_evaluations: 1` | |
| 198 | + |
| 199 | +## Adapting this to your own hot function |
| 200 | + |
| 201 | +1. Wrap the function in `EVOLVE-BLOCK-START` / `EVOLVE-BLOCK-END`. |
| 202 | +2. Write a **reference** implementation the evaluator owns (never handed to the |
| 203 | + candidate) and a **fidelity metric** with a hard threshold. |
| 204 | +3. Write the cheats and prove they score zero. Then go looking for the *localised* |
| 205 | + cheat your aggregate metric cannot see. |
| 206 | +4. Put the expensive measurement in the last cascade stage, and measure it fairly. |
| 207 | +5. Return artifacts explaining every rejection. |
| 208 | +6. Compare against a **competent** implementation, not just your slow seed — that is |
| 209 | + the only number that means anything. |
0 commit comments