
Someone at work asked whether our product-demo clips could get a "blur background" option. Before any screenshot debate I wanted a number a CI job could compute and fail on. I ended up with the mean luma difference between the composite and an empty-scene reference, taken in rings by distance from the subject, on pixels the matte marks as fully background. The metric is easy. The work is in the reference, and in proving the number reads zero when there's no halo.
The setup and the clip
What I measured were exports I made with ImgIng (https://imging.ai/) on 2026-10-20. The browser was Chromium 149 without GPU acceleration, on an M4 Mac. The matting feature is Beta, desktop Chrome or Edge only, nothing on phones. The clip is a 478×850, 24 fps sign-language video from Wikimedia Commons (uploaded by Sayvhior, signing by JudithNG12, CC BY-SA 4.0): one person, a light curtain, a dark wall at the side. Her hair is under a headscarf, so hair edges are out of scope. In blur mode the ring shows up because the person is still in the frame that gets blurred. Her matte came from the alpha channel of the transparent export, which is WebM only. None of my nine exports sent a non-GET request, which is the first thing our compliance people would ask.
Building a reference you can trust
Measuring "too dark" needs the curtain as it looks with nobody in front of it. I took, per pixel, the median over every other frame in which that pixel sat at least 6 px outside the matte, and only trusted pixels that had five or more such samples. That covered 62.5% of the frame. The rest, mostly behind her torso, was never visible and stays out of every statistic. Then the metric itself:
def halo_profile(result, reference, matte, trusted):
subject = (matte > 0.5).astype(np.uint8)
dist = cv2.distanceTransform(1 - subject, cv2.DIST_L2, 5)
diff = luma(result) - luma(reference)
profile = {}
for lo, hi in BANDS:
sel = (dist > lo) & (dist <= hi) & (matte < 0.02) & trusted
profile[(lo, hi)] = float(diff[sel].mean()) if sel.sum() >= 50 else None
return profile
Bands are 0–4, 4–8, 8–16, 16–32, 32–64 and 64–128 px, the last one as a noise floor. Luma is 0.299R + 0.587G + 0.114B on a 0–255 scale, so negative means darker than it should be.
Controls before trusting the number
First I ran two controls and a sanity check with my own plain Gaussian at sigma 18, on frames 36, 60, 100 and 156; three of those are the clip's fastest hand movements.
reference = over(frame, cv2.GaussianBlur(plate, (0, 0), sigma), matte)
blur_all = over(frame, cv2.GaussianBlur(frame, (0, 0), sigma), matte)
hole = cv2.dilate((matte > 0.02).astype(np.uint8),
cv2.getStructuringElement(cv2.MORPH_ELLIPSE, (9, 9)))
filled = cv2.inpaint(frame.clip(0, 255).astype(np.uint8), hole * 255, 9,
cv2.INPAINT_TELEA).astype(np.float32)
fill_blur = over(frame, cv2.GaussianBlur(filled, (0, 0), sigma), matte)
sharp_ref = over(frame, plate, matte)
| Four-frame mean ΔL | 0–4 | 4–8 | 8–16 | 16–32 | 32–64 | 64–128 |
|---|---|---|---|---|---|---|
| Blur whole frame | −42.5 | −21.8 | −9.0 | −3.2 | −0.4 | −0.2 |
| Fill 4 px past matte, then blur | +0.3 | +0.6 | +0.4 | +0.3 | 0.0 | −0.2 |
| No blur, sharp reference | +11.7 | +1.6 | +1.0 | +0.5 | −0.1 | −0.2 |
The positive control fades with distance like a smeared shadow, and the negative control stays within a level of zero, so blur alone doesn't make a ring here. Both rows are my own compositing, not a tool feature.
The middle panel is the negative control's input, and the fill is ugly: streaks and a faint pink smear where she stood. The bands still read near zero because the sharp person is pasted back over that area and only a thin blurred margin reaches the rings. Video: Sayvhior / Wikimedia Commons, CC BY-SA 4.0 (signing by JudithNG12); this comparison is shared under CC BY-SA 4.0.
The third row surprised me. With no blur anywhere, the 0–4 px band still reads +11.7, between +5.6 and +18.5 per frame. My guess is that the plate right next to where she usually stands picks up something dark, maybe motion-blurred arms or a shadow that stays under alpha 0.02, but I haven't pinned it down. So I log 0–4 and gate on the 4–16 average, which is −15.4 for the blur-all case and +0.5 for fill-then-blur.
What the actual exports read
For the exports I compared against the plate blurred at the same strength, averaged over the same four frames. Don't line these up against my Gaussian rows, since I don't know exactly how the tool blurs. At the default strength 18 the bands read −67.8, −39.0, −20.6, −11.2 and −1.8, with the far band between −1.1 and +1.8. At 40 they read −63.1, −41.7, −26.2, −21.8 and −13.6, with the far band between −2.4 and −0.7. I call −10 visible, which puts the ring at roughly 16–32 px out at 18 and past 32 px at 40. The innermost band is equally dark at both strengths, so a higher strength makes the ring wider without making it lighter at the edge.
Where the number misleads
It only sees the 62.5% of the frame the reference covers. Anything at alpha 0.02 or above is skipped, so feathering the matte wider can improve the score while the ring stays on screen. It's one clip, dark clothes on a light curtain; the reverse contrast should give a bright ring, which I haven't tested. Speed I only noted in passing: with no GPU in the loop, the quality tier needed roughly 4–5x the compatible tier's time. The MatAnyone2 tier is for non-commercial testing only, so I left it out. If we adopt this, whoever records the demos will have to roll on the empty set first so the reference covers the whole frame, and the check will compare the 4–16 px band with the 64–128 px floor.
Top comments (0)