Automatically switch OBS scenes when a broadcast goes to an ad break.
A channel "bug" — the small, often semi-transparent station logo in the corner of the screen — is usually hidden during commercials and shown during normal programming. This tool watches a live TV feed over NDI, detects whether that logo is present, and flips OBS between two scenes accordingly:
- logo present → normal programming → show your
tvscene - logo absent → ad break → show your
adbreakscene (a calm loop, music, a "back shortly" card, whatever you like)
Detection uses OpenCV template matching against a cropped image of the logo.
Heads up — this is a hobby project. It works for a fixed, static logo but it is not magic: template matching is sensitive to scale, and stations that animate, move, or briefly hide the logo will cause false switches. See Limitations.
TV / capture card ──NDI──► tvadblocker ──WebSocket v5──► OBS Studio
(raw broadcast) (this program) (scene switch)
│
OpenCV logo detection
+ hysteresis debounce
- NDI in — the raw broadcast is published as an NDI source.
tvadblockerreceives its frames. - Detect — every
interval_secondsit grayscales the top-right region, template-matches the logo (using the logo PNG's alpha as a mask), and gets a match score in[0, 1]. - Debounce — a hysteresis state machine requires several consecutive agreeing frames before it believes the state really changed, so a single noisy frame or a momentary logo fade won't flip scenes.
- Switch — on a confirmed change it calls OBS WebSocket v5 to set the program scene. It only switches on an actual transition, never per frame.
⚠️ Feed the raw broadcast, not OBS's program output. The NDI stream you point this at must always show the real TV picture, independent of which scene OBS is currently displaying. If you feed it OBS's program output, then switching toadbreakremoves the logo from what the detector sees, and it gets stuck. Publish a dedicated NDI output of the capture device/source.
- Python 3.10+
- OBS Studio 28+ with WebSocket v5 (built in: Tools → WebSocket Server Settings). Note the default port is 4455 (v4's 4444 is gone).
- An NDI source carrying the raw broadcast. In OBS this is typically the DistroAV (formerly obs-ndi) plugin publishing your capture source as NDI.
- The NDI runtime/SDK installed on your machine (the
ndi-pythonwheel needs it to discover and decode sources). On Linux, source discovery also needs Avahi.
git clone https://github.com/paratustra/tvadblocker
cd tvadblocker
python -m venv .venv && source .venv/bin/activate
pip install -e . # or: pip install -r requirements.txtCopy the example and edit it (your real config.ini is git-ignored so you
never commit your OBS password):
cp config.example.ini config.ini[obs]
host = localhost
port = 4455
password =
tv_scene = tv
ad_scene = adbreak
[ndi]
# Case-insensitive substring to pick a source; empty = first one found.
source_name =
[detection]
logo_path = logo.png
present_threshold = 0.90 # score >= this -> logo present (program)
absent_threshold = 0.85 # score <= this -> logo absent (ad)
debounce_frames = 3 # consecutive agreeing frames before switching
interval_seconds = 1.0 # how often to sample
roi_width_ratio = 0.25 # size of the top-right search region
roi_height_ratio = 0.25
logo_capture_width = 0 # width (px) the logo was cropped from; 0 = no rescaleThe gap between absent_threshold and present_threshold is a dead zone:
scores inside it don't change the state, which prevents flapping at the
boundary. Tune the two thresholds by watching the logged scores during real
programming vs. real ads and placing them in the valley between the two.
- Crop
logo.pngtightly around the logo from a frame of the same feed you will run against. - Save it as a PNG with transparency if the logo is semi-transparent — the alpha channel is used as a match mask, which is what makes a translucent bug detectable over changing content.
- Template matching is not scale-invariant. If you cropped from a different
resolution than the live feed, set
logo_capture_widthto the width you cropped from and the template is rescaled to match automatically.
- Create two scenes named to match your config:
tvandadbreak. - Put your capture device in the
tvscene, and publish it as a dedicated NDI source (DistroAV: NDI Output / Dedicated NDI Output filter on the capture source — not the program output; see the caveat above). - Enable Tools → WebSocket Server Settings, set a password, note the port.
tvadblocker --config config.ini --log-level INFO
# or, without installing the console script:
python -m tvadblocker --config config.iniRun at --log-level DEBUG while tuning to see per-frame scores. Stop with
Ctrl+C.
src/tvadblocker/
config.py # dataclass config + validation (pure stdlib)
detector.py # LogoDetector (OpenCV) + AdStateMachine (hysteresis)
ndi_source.py # NDI receiver (safe frame capture/free)
obs_controller.py # OBS WebSocket v5 client with reconnect
app.py # the detection loop
cli.py # argument parsing / entry point
tests/ # unit tests for config, detector, state machine
pip install -e ".[dev]"
ruff check src/ tests/ # lint
mypy # strict type check
pytest # testsThe native NDI/OBS bindings need hardware, so the tests and CI exercise only the pure logic (config, detection math, state machine); the hardware wrappers are kept import-isolated so that stays possible.
- Template matching only. No scale/rotation invariance beyond the single configured rescale; a logo that moves or resizes will not match.
- Animated/disappearing logos. Channels that briefly hide the logo during programming (lower-thirds, full-screen graphics) can cause false ad switches. The debounce mitigates blips, not sustained ones.
- Per-station tuning. Thresholds and the logo crop are specific to one channel and one feed resolution.
- No audio/scene-content analysis. It's a purely visual, single-cue heuristic.
MIT © Francisco Parata