Sriganesh Srinivasan
← All work

TV Ambilight.

Room lights that follow what's on the TV

2026 · Solo · Shipped

Code ↗
Problem
TVs with Philips-style ambient lighting, or a dedicated capture box, cost extra. The smart bulbs and LED strip already in my room had no idea what was on screen.
Built
A Python tool that grabs the Mac's mirrored display, finds each screen region's dominant color with a hue histogram, smooths it, and sends it straight to two LIFX bulbs and a Govee strip over their local UDP protocols.
Stack
Python, NumPy, mss, UDP, LIFX LAN protocol, Govee LAN API
Outcome
It runs at 12 frames a second with no cloud or accounts. The lights fade smoothly, snap on scene cuts, and go back to how they were when you quit.

What it does

Mirror the Mac to the TV over AirPlay or HDMI, then run ambilight.py run. Three lights follow three parts of the picture:

discover finds the lights on the network, and setup flashes each bulb red in turn and asks which one is which. Ctrl-C stops it and restores every light’s original state.

The strip behind the TV following the movie.

How it works

Every frame, about 12 times a second:

  1. Capture the main display with mss (about 20 ms per grab), then shrink it to roughly 190 pixels wide. That’s plenty for picking colors and keeps the math cheap.
  2. Crop black bars, so letterboxed movies don’t push the regions off the actual picture.
  3. Pick each region’s dominant color (more below).
  4. Smooth it with an exponential moving average so the lights don’t flicker, unless the color jumps by a lot. Then it’s a scene cut, and the light snaps most of the way at once.
  5. Send it straight to the lights over UDP: a hand-built binary packet for LIFX, with a fade time so the bulb blends between frames itself, and a small JSON message for Govee.
TV Ambilight architectureA Mac mirrors its display to the TV. Twelve times a second, a Python script captures the screen, crops black bars, picks the dominant color of three regions, smooths it, and sends it over UDP to two LIFX bulbs and a Govee LED strip on the local network.TVMirrored displayshows the MacAIRPLAY/ HDMIMAC · PYTHONambilight.py · 12 fps01Capture the screenmss · ~20 ms · shrink to ~190 px wide02Crop black barsletterbox and pillarbox03Dominant color × 3 regions24-bin hue histogram, not an average04Smoothmoving average · snaps on scene cutsLOCAL WIFI · NO CLOUDLIFX BULBTop-left of screenUDP 56700 · binaryLIFX BULBBottom-left of screenUDP 56700 · binaryGOVEE LED STRIPRight of screenUDP 4003 · JSON · ≤ 5/s
The whole loop runs on the Mac, 12 times a second. Nothing leaves the local network.

I talk to the lights directly over the local network instead of through either brand’s cloud API. That means no accounts, no internet dependency, and much lower latency. The LIFX client is about 100 lines of struct packing written against the published LAN protocol, rather than a library.

I chose to capture the Mac’s screen instead of buying an HDMI capture card. The trade-off: the menu bar and Dock count as part of the picture.

Picking the right color

Averaging a region’s pixels gives mud: a red scene with some blue in it averages to purple. So the tool picks the majority color instead. It sorts colorful pixels into 24 hue buckets, weighted by how saturated and bright they are, merges neighboring buckets, and takes the winner. Mostly grey regions fall back to white, which LIFX shows as a proper color temperature instead of a washed-out tint.

A generated sunset frame on a TV, with the three screen regions outlined and each light glowing in the color the code picked.
A generated sample frame with the three screen regions marked. The glows are the colors my code picked for each light.
Three pairs of color swatches. A plain average gives muddy mauve and grey-blue; the majority color gives vivid orange-red and blue.
The same frame: my actual color-picking code versus a plain average. A plain average is muddy; the majority color is what the scene looks like.

What broke

A smaller one: without macOS’s Screen Recording permission, captured frames come back black and the lights sit at their minimum brightness. The README calls this out. There’s also a brightness floor so the lights never go fully dark on black frames.

What I’d do next

I haven’t measured end-to-end latency (screen to light) yet, and that’s the number that matters most for how it feels. I’d also like more regions, and to drive the LED strip’s individual segments instead of one color.

Next projectBird Feeder Detector →