Skip to main content

vizor

A small detector is fast enough to run on every frame but gets classes wrong. A vision-language model gets them right but is far too slow to run on every frame, and often gives you no boxes at all. Vizor runs both and keeps the useful half of each: boxes and track ids come from the detector, class labels come from the VLM, and every VLM answer is cached against the track id so the same object is never asked about twice.

Full documentation is at y-t-g.github.io/vizor. Everything below runs from this repo, and examples/ holds each snippet as a script you can run.

Install

The core needs numpy and OpenCV only. The model wrappers are optional extras, so install the one you actually use:

pip install vizor              # core
pip install "vizor[hf]"        # local Florence-2 or another transformers VLM
pip install "vizor[api,groq]"  # hosted VLMs over OpenAI or Groq

Model wrappers are imported on first use, so import vizor never pulls in torch if you are not using a torch model.

vizor ships no detector. It ships the base class and one working adapter you copy. examples/yolo.py is about 60 lines and wraps ultralytics, which is AGPL-3.0, so it lives outside the package and carries its own licence header. Read detectors and licensing before you use it.

Quick start

YOLO on every frame, a hosted VLM on the boxes YOLO is unsure of. Yolo here is the adapter from examples/yolo.py, not something you import from vizor:

import vizor as vz
from yolo import Yolo

viz = vz.Vizor(Yolo("yolo11n.pt"), vz.Vlm("gpt-4o-mini"), conf=0.5, mode="crop")

for out in viz.run("traffic.mp4", save="out.mp4"):
    print(len(out), "boxes")

That writes an annotated out.mp4 and yields the refined boxes for every frame. The key is read from OPENAI_API_KEY, never passed in code.

Florence-2 instead, running locally and grounding the whole frame in one pass:

import vizor as vz
from yolo import Yolo

names = {0: "person", 2: "car", 7: "truck"}
viz = vz.Vizor(Yolo("yolo11n.pt"), vz.Florence(names=names), conf=0.5, mode="full")

for out in viz.run(0, show=True):   # 0 is the first webcam
    pass

Florence gives boxes as well as labels, so the refiner can correct the box too, not just the class.

To try it with no models at all, replay the cached predictions:

python examples/replay.py --save out.mp4

On this machine that prints 3600 frames in 9.6s (376 fps) and leaves 139 tracks in the vote cache.

How it works

Each frame goes through three steps.

  1. The primary detects and tracks. You get boxes, confidences, classes and track ids.
  2. Tracks at or below conf are handed to the secondary. Everything above it is left alone.
  3. The secondary's answer is recorded as a vote against the track id. The running majority overwrites the class on this frame and on every later frame, whether or not the secondary runs again.

Step 3 is what makes this affordable. A car that stays in view for 300 frames costs one VLM call, not 300.

There are two modes.

mode="full" runs the secondary on the whole frame, matches its boxes to the tracks by IoU, and takes its box, confidence and class. Use it when the secondary localises: Florence-2, a heavier YOLO, any open-vocabulary detector.

mode="crop" cuts each low confidence track out of the frame and asks the secondary what it is. The box stays as the primary drew it and only the class changes. Use it when the secondary classifies but does not localise, which is every chat VLM.

Numbers

The repo ships YOLOv5n tracks and Florence-2 grounding output for traffic3.mp4, 3600 frames. Reproduce the table with python examples/bench.py:

 conf   boxes  relabelled  ms/frame  low conf  vlm calls
 0.30   48756        2518      0.11      2331        336
 0.50   48756        2342      0.15      8457        569
 0.90   48756        6443      0.22     47579        596

relabelled and ms/frame come from full mode against the Florence output. low conf and vlm calls come from crop mode with a stub secondary. At conf=0.5 there are 8457 boxes below the threshold across the video, and the vote cache turns them into 569 calls. The ms/frame column is the refiner's own cost, which is the IoU match and the vote lookup. The secondary's inference cost is on top of that and is the only part that matters in practice.

The refiner never adds or drops a box. It only rewrites the class, and in full mode the box and confidence too.

API

vz.Vizor(primary, secondary=None, conf=0.5, mode="full", names=None, **kw)
  • conf sends tracks at or below this confidence to the secondary.
  • mode is "full" or "crop".
  • names maps class ids to strings. Defaults to whatever the primary reports.
  • iou is the minimum overlap to match a secondary box to a track, full mode only.
  • votes is how many answers to collect per track before the secondary stops being asked, crop mode only. Default 1.
  • size and hist cap the vote cache at that many track ids and that many votes each.

viz.run(src, save=None, show=False) yields refined Tracks for every frame of a file, a camera index, or a stream url. viz.step(img) does one frame. viz.save(src, out) runs the whole thing and writes the annotated video. viz.reset() clears the votes and the primary's tracker between videos.

Tracks wraps a float32 array of shape (N, 7) holding [x1, y1, x2, y2, conf, cls, id], with id = -1 for untracked boxes:

out.data      # the raw array
out.boxes     # (N, 4) xyxy
out.conf, out.cls, out.ids
out[0]        # a single Track
out[out.conf > 0.8]   # a copy holding the rows that match
out.draw()    # annotate the frame it came from and return it

Indexing with an int gives you one Track. Indexing with a mask or a slice gives you a new Tracks, and because numpy copies on fancy indexing, writing to it does not touch the original.

Bundled models: Vlm (OpenAI, Groq, or any OpenAI-compatible url), Hf (a local transformers chat VLM), Florence (Florence-2 as an open-vocabulary detector), Pkl (replay saved predictions). All of them are secondaries. The primary is yours to bring.

Detectors and licensing

vizor is Apache-2.0. Ultralytics is AGPL-3.0, and AGPL says a work that combines with it must also be AGPL-3.0. A Python module that imports ultralytics forms that combined work when it runs, so shipping a YOLO wrapper inside an Apache-2.0 wheel would put the two licences in conflict.

So the wrapper is not in the package. It is examples/yolo.py, marked AGPL-3.0-or-later, and pip never installs it. Nothing that pip installs imports ultralytics.

What that means for you. If your own project is AGPL-3.0, or you hold an Ultralytics Enterprise licence, copy examples/yolo.py and use it. If your project is closed source or permissively licensed, write an adapter for a detector whose licence you can live with. The interface is one method, and writing your own model below shows it.

I am not a lawyer and this is not legal advice. If the answer matters commercially, ask one.

Writing your own model

Subclass vizor.Model and implement the method your role needs. A primary implements track, a full-mode secondary implements find, a crop-mode secondary implements name. Anything you leave out raises on the first frame with a message saying which role is missing, rather than silently doing nothing.

import numpy as np
from vizor import Model, Tracks

class MyDetector(Model):
    names = {0: "person", 1: "car"}

    def track(self, img):
        # your model here, returning [x1, y1, x2, y2, conf, cls, id] rows
        return Tracks(np.zeros((0, 7), np.float32), names=self.names)

Images handed to your model are BGR, the layout OpenCV gives you. Convert inside your wrapper if the model wants RGB. name returns a class id, or None if the model is not sure, and None records no vote.

Caveats

The vote cache is keyed on the track id, so it is only as good as the tracker. If the tracker swaps ids between two nearby objects, the refined class follows the id and lands on the wrong object. Raising hist makes a single bad frame matter less but does not fix an id swap.

Untracked boxes all carry id = -1, so the cache skips them. They are refined on the frame they appear on and never remembered.

Florence-2 reports no confidence, so every box it returns comes back at 1.0. In full mode that overwrites the primary's confidence with a number that means nothing. Set best=False if you would rather every matching box vote instead of only the highest-IoU one, but the confidence problem stays.

Nothing here is batched. Crop mode sends one request per low confidence track, one at a time. Batching the crops of a frame into a single request would cut the latency a lot, and is the obvious thing to add next.

There is no evaluation against ground truth. The table above counts how many labels changed, not how many of the changes were right.

Links

Download files

Download the file for your platform. If you're not sure which to choose, learn more about installing packages.

Source Distribution

vizor-0.2.0.tar.gz (31.4 kB view details)

Uploaded Source

Built Distribution

If you're not sure about the file name format, learn more about wheel file names.

vizor-0.2.0-py3-none-any.whl (27.0 kB view details)

Uploaded Python 3

File details

Details for the file vizor-0.2.0.tar.gz.

File metadata

  • Download URL: vizor-0.2.0.tar.gz
  • Upload date:
  • Size: 31.4 kB
  • Tags: Source
  • Uploaded using Trusted Publishing? No
  • Uploaded via: twine/7.0.0 CPython/3.13.14

File hashes

Hashes for vizor-0.2.0.tar.gz
Algorithm Hash digest
SHA256 abadbfd819afc2b28ee391a0534e85772e405fd7850d002898e164e0d71ce236
MD5 669b948187440360090e06861db0a723
BLAKE2b-256 e2b80c5de4fba1a8b2e5cd22c6bf7bf545fdffadb4dd9517d917c1683b65fc4a

See more details on using hashes here.

File details

Details for the file vizor-0.2.0-py3-none-any.whl.

File metadata

  • Download URL: vizor-0.2.0-py3-none-any.whl
  • Upload date:
  • Size: 27.0 kB
  • Tags: Python 3
  • Uploaded using Trusted Publishing? No
  • Uploaded via: twine/7.0.0 CPython/3.13.14

File hashes

Hashes for vizor-0.2.0-py3-none-any.whl
Algorithm Hash digest
SHA256 0027a407dbdbf39844d26361e343bff337ac7fb76c7acbca89f6e7a3017f3969
MD5 2fa9be3d9956da11720712c37178e9de
BLAKE2b-256 0e48cf2add951c202eae3eb7c484d5554414009540278d7ba0809c8a1a48b1b6

See more details on using hashes here.

Release history Release notifications | RSS feed

0.5.0

2 files

0.3.0

2 files

This release

0.2.0 This release

2 files

0.1.0

2 files

0.0.0

2 files

Anthropic, PBC Visionary sponsor Bloomberg Visionary sponsor Hudson River Trading Visionary sponsor Meta Visionary sponsor NVIDIA Visionary sponsor Microsoft Sustainability sponsor Depot Continuous Integration AWS Cloud computing and Security Sponsor Datadog Monitoring Fastly CDN Google Download Analytics Sentry Error logging StatusPage Status page