GitHub - garagehq/Minus-streaming-stick: Minus is a plug-and-play HDMI accessory that stops ads from your streaming devices and swaps them for content you choose. 100% open source—hardware, firmware, and tools included.

GitHub

12 min read Original article ↗

Video Demo

Slides From HOPE Talk

Minus is an open-source HDMI box that sits between your streaming device and your TV. It watches the video feed, detects ads with ML models running locally on its own NPUs, then mutes and covers them until your show comes back. Instead of the ad you get something useful, like a Spanish vocabulary card.

Nothing leaves your living room. There is no cloud service, no subscription, and no account. The OCR, the vision model, and the speech recognition all run on the device. The one exception is something you have to switch on yourself: you can choose to share anonymous snapshots of blocked ads to help train the next model. It's off by default; see docs/PRIVACY.md.

Minus blocking an ad and showing a Spanish vocabulary card instead

An ad break as seen through Minus. The ad is muted and covered by a vocabulary card, with a small greyscale preview in the corner so you can tell when it's over.

How it works

Minus is a 4K@60fps HDMI passthrough. Your Roku, Fire TV, or Google TV plugs into Minus, and Minus plugs into your TV. Three detectors watch the stream and vote on whether an ad is playing:

┌──────────────┐     ┌────────────────────┐     ┌─────────────────────┐
│   HDMI IN    │────▶│     ustreamer      │────▶│  HDMI OUT to TV     │
│ (Roku, etc.) │     │ (MPP hw encoding)  │     │  (DRM/KMS, 60fps)   │
└──────────────┘     └────────┬───────────┘     └─────────────────────┘
                              │ snapshots + audio
              ┌───────────────┼────────────────┐
              ▼               ▼                ▼
     ┌────────────────┐ ┌──────────────┐ ┌──────────────┐
     │  PaddleOCR     │ │  minus-v0.1  │ │  Moonshine   │
     │  RK3588 NPU    │ │  (VLM)       │ │  ASR (CPU)   │
     │  reads ad UI   │ │  Axera NPU   │ │  hears ad    │
     │  text ~200ms   │ │  ~370ms      │ │  language    │
     └────────────────┘ └──────────────┘ └──────────────┘
              └───────────────┼────────────────┘
                              ▼
                    blocking decision engine
              (mute + overlay, typically < 2s to react)
  • OCR (PaddleOCR PP-OCRv6 on the RK3588's NPU, ~200ms a frame) reads on-screen ad UI like "Skip in 5", "Ad 2 of 3", and countdown timers, including the character misreads OCR makes on real TVs. When an ad shows its own countdown, Minus reads the number and counts it down in real time, so it knows the ad is still running even while OCR briefly loses the text. That clock only ever helps hold a block that the detectors already started. On its own it never starts one.
  • minus-v0.1, a 450M-parameter vision-language model fine-tuned for this exact job, looks at the raw frame and answers "is this an advertisement?" in about 370ms on an Axera AX8850 NPU. It needs no text on screen to work.
  • ASR (Moonshine tiny-en on CPU) transcribes the audio and listens for marketing language as a confirmation signal.

When the votes say "ad", audio mutes immediately and the screen switches to an overlay rendered at 60fps inside the hardware JPEG encoder (no desktop stack, no X11). When the ad ends the show comes back after a few seconds, about 5 in practice. Minus waits for several clean frames and a short floor before it lets go, because OCR routinely loses an ad's text for a few seconds mid-ad, and releasing on the first gap would flash the ad back on screen.

The model

minus-v0.1 is published on Hugging Face: TheGarageDev/Minus-v0.1.

It's a fine-tune of LiquidAI's LFM2.5-VL-450M trained on frames captured from real TV streams and compiled into fused-layer models for the Axera NPU. Inference is prefill-only: the yes/no answer is read straight from the logits without generating any tokens, so latency is deterministic.

Metric Value
Accuracy (800-image holdout) 97.0%
Ad recall 94.8%
Non-ad recall (false-positive resistance) 99.2%
Inference latency ~0.37s per frame
Parameters 450M

The same model also classifies screen state (playing / paused / menu / dialog / screensaver) for autonomous mode.

What ships where

The two models are distributed differently, which trips people up when setting up a new board:

Model Runs on Where it comes from
PaddleOCR PP-OCRv6 detection + recognition (default), plus PP-OCRv3 as a rollback RK3588 NPU In this repository, models/paddleocr/. ~17 MB for both generations, nothing to download.
minus-v0.1 (450M VLM) Axera AX-M1 Hugging Face, the axera/ folder (~740 MB). install.sh downloads it into /home/radxa/axera_models/minus-v0.1/; which model and where are set in models/vlm_model.json

OCR needs three files per generation, all present in the repo: a detection model that finds text regions, a recognition model that reads each region, and the character dictionary the output is decoded through. Each generation has its own dictionary, and the two are not interchangeable. MINUS_OCR_MODEL_VERSION picks one: v6 (the default) or v3. The loader keeps each model paired with its own dictionary and thresholds, so they can't be mixed up. v6 was chosen on latency: 183ms p50 and 274ms p90 in production, and a tighter tail that keeps text-heavy ad frames under the hard timeout. A rotated-text classifier is also included, but Minus doesn't load it, since TV overlay text is never upside down. models/paddleocr/README.md has the details and checksums.

Features

  • Real-time ad blocking via OCR + VLM + ASR triangulation. The decision engine handles the ugly cases: flicker between ads, paused screens that look like ads, black transition frames, frozen streams.
  • Reads an ad's own countdown ("Skip in 12", "Ad 0:15") and counts it down to know how much of the ad is left. It's an extra signal on top of the detectors, never a trigger by itself. It lets go if the countdown freezes or the audio goes silent, which is what a paused video looks like.
  • Ads get replaced, not just blacked out. Choose Spanish vocabulary cards (750+ entries), trivia facts, or a photo screensaver of your own pictures.
  • 4K@60fps passthrough with a zero-copy VPU/RGA pipeline in a patched ustreamer, around 5% CPU.
  • Audio mutes the instant a block starts, with watchdogs that recover the pipeline if anything wedges, including re-initialising the HDMI output when the TV comes back so sound isn't left silent.
  • Thermal-adaptive: if the SoC holds at 83 °C, Minus caps the display at a steady 30fps and makes blocking stickier until it cools below 75 °C, rather than letting the frame rate collapse under throttling.
  • Web UI with the live feed, pause controls, detection history, screenshot review, and settings, usable from any device on your network.
  • Remote control integration for Fire TV (ADB), Roku (ECP), and Google TV (ADB), including pressing "Skip Ad" for you.
  • Autonomous mode keeps YouTube playing unattended to collect training data, midnight to 9am by default, using the VLM to navigate. It stands down the moment the TV is turned on so it never fights you for the remote, and if the streaming stick has put itself to sleep it presses Home to wake it. A music-video mode steers toward music, which carries far more ads per hour.
  • Odds and ends: IR blaster for HDMI switches, WS2812B status LED strip, WiFi captive portal for first-time setup, HDCP 1.4 capture support.

Web UI

Desktop Mobile
Minus web UI on desktop Minus web UI on mobile

The web UI is served by the device itself (Flask). Live feed, block/pause controls, per-detection history, a swipe-based screenshot review flow for cleaning up training data, device remotes, and every setting. A status row shows OCR, VLM and ASR state alongside CPU, memory, temperature, disk and HDMI signal, and turns amber or red as things get tight. No app to install.

Hardware

Roughly $350-$450 in parts. Full bill of materials, part links, and sizing notes: docs/HARDWARE.md.

Component Notes
Radxa ROCK 5B+ or ROCK 5B, 8 GB RAM recommended (4 GB minimum) RK3588 SoC. The 4K@60 HDMI input is the part that matters: boards without one can't do passthrough. Also provides the NPU for OCR and the VPU for 4K60 JPEG encoding.
Radxa AICore AX-M1 (Axera AX8850, M.2 2280 M-key, 24 TOPS, 8 GB) Runs minus-v0.1 at ~370ms per inference
Radxa Heatsink 6240B, heatsink and fan (docs) Required. This is Radxa's cooler for the ROCK 5B / 5B+ / 5T. For the 5B+, get V2.0: V1.0 has fins that hit the 5B+. 4K60 passthrough runs around 80 °C with the fan going; past 83 °C Minus drops to 30fps until it cools. Passive cooling alone will throttle.
Radxa Power PD 30W, or any 30 W+ USB-C PD supply Required. Radxa's own supply is the one they test the ROCK 5B against. An underpowered supply (a 5 V/2 A phone charger, say) causes random pipeline restarts.
2x certified High Speed HDMI cables, 18 Gbps Source → Minus → TV. Keep them short; marginal cables show up as "No Signal" at 4K60.
Optional: 64 GB+ microSD card (A2-rated) Holds the OS if your board has no onboard eMMC. The ROCK 5B+ can be ordered with eMMC built in; the ROCK 5B boots from microSD or an eMMC module.
Optional: IR LED (940 nm) on GPIO Controls an HDMI switch for multi-device setups, see docs/IR_TRANSMITTER.md
Optional: WS2812B 8-LED strip Status indicator (idle / blocking / error / ...), see docs/STATUS_LEDS.md
Optional: HDCP 1.4 sink key For capturing HDCP-protected sources, see hdcp/

Operating system: Debian 12 (bookworm) on Radxa's BSP 6.1 kernel, which is what their official ROCK 5B/5B+ images ship. This is not interchangeable with a mainline kernel: the HDMI receiver (rk_hdmirx), the Rockchip MPP encoder, RGA, and the RKNN NPU runtime all live in Rockchip's 6.1 tree. On a mainline kernel the board boots fine and /dev/video0 simply never appears. Either the KDE or the CLI image works; there is no display server or desktop environment involved at runtime, since Minus talks straight to DRM/KMS and the hardware encoders.

Getting started

git clone https://github.com/garagehq/Minus-streaming-stick.git /home/radxa/Minus
cd /home/radxa/Minus

# Install system + Python dependencies (GStreamer, fonts, rknnlite, axengine, ...)
# then build the patched ustreamer:
git clone https://github.com/garagehq/ustreamer.git /home/radxa/ustreamer-garagehq
cd /home/radxa/ustreamer-garagehq && make WITH_MPP=1
cp ustreamer /home/radxa/ustreamer-patched

# Install as a service that starts on boot. This also downloads the vision
# model (~740 MB from Hugging Face; the OCR models already ship in this repo):
cd /home/radxa/Minus && sudo ./install.sh

# ...or run directly (fetch the model first if install.sh hasn't):
sudo python3 tools/minus_update.py --install-model
sudo python3 minus.py

Staying up to date

Settings → Minus Updates in the web UI shows whether this box is current and updates it with one button:

Settings → Minus Updates on a box: Minus, ustreamer and the vision model, all up to date after an update

  • Minus — on a published release, compared with the latest GitHub release; running from source, compared with the latest commit on your branch (normally main).
  • ustreamer — the /home/radxa/ustreamer-garagehq checkout against its branch; rebuilt when it changes.
  • Vision model — the files in /home/radxa/axera_models/ against the model the code asks for on Hugging Face (models/vlm_model.json). A new model (for example Minus v0.2) arrives the same way: after the code update, the new model is downloaded, checked and swapped in.

Update now pulls what's new (fast-forward only — it never overwrites local changes; a branch with local commits or no upstream is reported but left alone), rebuilds ustreamer if needed, downloads the model if needed, and restarts Minus, about a minute without picture. Minus has to come back up within four minutes and stay up (30 seconds on the same process, so a crash loop doesn't pass), and if the vision model was loaded before the update it has to load again; otherwise the code, ustreamer and model are all rolled back and it restarts on the previous version. Only one update can run at a time, and an update that was cut off (power cut) shows as interrupted instead of blocking the button. The previous model is kept next to the new one as <model dir>.prev. A check runs five minutes after startup and at most hourly after that while the page is open; nothing updates without the button. From a shell: sudo python3 tools/minus_update.py does the same thing.

Releases are cut by hand with tools/release.sh — see docs/RELEASING.md. A box on a release is only ever moved to a newer release; a box running from source follows its branch.

Automatic security updates

install.sh installs Debian's unattended-upgrades, and Minus turns it on by default (Settings → System Updates in the web UI toggles it, or set "unattended_upgrades": false in ~/.minus_system_settings.json). Only bookworm-security fixes are applied, it never reboots by itself, and the packages the video path depends on (kernel, u-boot, Rockchip MPP/RGA, GStreamer, Mesa, RKNN, Tailscale) are blacklisted so a routine run can't swap a driver the pipeline was validated against — update those deliberately with apt when you choose to. Policy lives in /etc/apt/apt.conf.d/52minus-unattended-upgrades (written by the service); history is in /var/log/unattended-upgrades/. If the web UI says the package is missing: sudo apt-get install unattended-upgrades.

The full dependency list and deployment walkthrough are in docs/DEPLOYMENT.md. Model paths, the OCR generation and detection thresholds are set with environment variables (MINUS_VLM_MODEL_DIR, MINUS_OCR_MODEL_DIR, MINUS_OCR_MODEL_VERSION, etc.); CLAUDE.md documents all of them.

Minus auto-detects the connected HDMI output, resolution, DRM plane, and audio device at startup, and works with both 4K and 1080p displays.

Documentation

Document Description
docs/HARDWARE.md Bill of materials, OS and kernel requirements
docs/FEATURES.md Complete feature list
docs/ARCHITECTURE.md System architecture and data flow
docs/DEPLOYMENT.md Setting up a new device from scratch
docs/API.md Web UI REST API
docs/PRIVACY.md What stays on the box, and the opt-in anonymous ad-snapshot contribution
docs/RELEASING.md Cutting a release, shipping a new vision model
docs/ASR.md Audio-based ad confirmation
docs/AUDIO.md Audio passthrough pipeline
docs/AESTHETICS.md Visual design guide
docs/IR_TRANSMITTER.md IR blaster for HDMI switches
docs/STATUS_LEDS.md WS2812B status strip
hdcp/README.md HDCP 1.4 setup for the HDMI input
CLAUDE.md Engineering notes: detection tuning and the full bug history

Related projects

Support

Minus is free, open-source, and runs entirely on your own hardware. No ads, no tracking, no server bills passed on to you. If it has saved you from a few unskippable ad breaks, you can buy me a coffee ☕:

Buy Me A Coffee

buymeacoffee.com/cyrilengmann

License