Skip to content

Models

Model is the base class. Every wrapper below subclasses it and fills in the methods for the roles it can play. See Models for which model fills which role.

Model

Base class for both roles.

A primary implements track. A secondary implements find, name, or both, depending on which refiner mode it is meant to serve. Anything not implemented raises, so a mismatch between model and mode fails loudly on the first frame instead of silently doing nothing.

names is the class id to name mapping the model works in. Leave it None if the model has no fixed vocabulary.

track

track(img)

Detect and track on a BGR frame. Returns Tracks.

Source code in vizor/models/base.py
def track(self, img):
    """Detect and track on a BGR frame. Returns [Tracks][vizor.boxes.Tracks]."""
    raise NotImplementedError(f"{type(self).__name__} cannot be a primary model")

find

find(img, names=None)

Detect on a whole BGR frame. Returns Preds.

Source code in vizor/models/base.py
def find(self, img, names=None):
    """Detect on a whole BGR frame. Returns [Preds][vizor.boxes.Preds]."""
    raise NotImplementedError(f"{type(self).__name__} does not support mode='full'")

name

name(crop, names=None, hint=None)

Classify a BGR crop. Returns a class id, or None if unsure.

hint is what the primary thought the object was.

Source code in vizor/models/base.py
def name(self, crop, names=None, hint=None):
    """Classify a BGR crop. Returns a class id, or None if unsure.

    ``hint`` is what the primary thought the object was.
    """
    raise NotImplementedError(f"{type(self).__name__} does not support mode='crop'")

batch

batch(crops, names=None, hints=None)

Classify several BGR crops. Returns one class id or None per crop.

The default asks name once per crop, so a model that only implements name works unchanged. Override this when the model can do the whole list in one call, which is what makes crop mode cheap.

hints is what the primary thought each object was, in the same order.

Source code in vizor/models/base.py
def batch(self, crops, names=None, hints=None):
    """Classify several BGR crops. Returns one class id or None per crop.

    The default asks ``name`` once per crop, so a model that only implements
    ``name`` works unchanged. Override this when the model can do the whole
    list in one call, which is what makes crop mode cheap.

    ``hints`` is what the primary thought each object was, in the same order.
    """
    hints = list(hints) if hints is not None else [None] * len(crops)
    return [self.name(c, names, h) for c, h in zip(crops, hints)]

grid

grid(collages, names=None, hints=None, tiles=1)

Classify collages, each one object shown tiles times over a video.

Returns one class id or None per collage, the same shape as batch. The default forwards to batch, which treats a collage as an ordinary image, so a model that has never heard of collages still answers. Override it to word the prompt for a grid.

Source code in vizor/models/base.py
def grid(self, collages, names=None, hints=None, tiles=1):
    """Classify collages, each one object shown ``tiles`` times over a video.

    Returns one class id or None per collage, the same shape as ``batch``.
    The default forwards to ``batch``, which treats a collage as an ordinary
    image, so a model that has never heard of collages still answers.
    Override it to word the prompt for a grid.
    """
    return self.batch(collages, names, hints)

reset

reset()

Drop any per-video state. Called by Vizor.reset.

Source code in vizor/models/base.py
def reset(self):
    """Drop any per-video state. Called by ``Vizor.reset``."""

menu

menu(names, limit=200)

Render a class mapping as id: name lines for a prompt.

Source code in vizor/models/base.py
def menu(names, limit=200):
    """Render a class mapping as ``id: name`` lines for a prompt."""
    if names is None:
        return ""
    items = names.items() if isinstance(names, dict) else enumerate(names)
    items = list(items)[:limit]
    return "\n".join(f"{int(k)}: {v}" for k, v in items)

parse_id

parse_id(text, valid=None)

Pull a class id out of a model's reply. None if there isn't a usable one.

Source code in vizor/models/base.py
def parse_id(text, valid=None):
    """Pull a class id out of a model's reply. None if there isn't a usable one."""
    import re

    if text is None:
        return None
    found = re.search(r"-?\d+", str(text))
    return _check(found.group(), valid) if found else None

parse_ids

parse_ids(text, n, valid=None)

Pull n class ids out of a batched reply, in order.

A reply with too few numbers is padded with None, one with too many is cut. Either way the caller gets exactly n entries, so a model that miscounts costs some crops their refinement rather than shifting every later answer onto the wrong object.

Source code in vizor/models/base.py
def parse_ids(text, n, valid=None):
    """Pull ``n`` class ids out of a batched reply, in order.

    A reply with too few numbers is padded with None, one with too many is cut.
    Either way the caller gets exactly ``n`` entries, so a model that miscounts
    costs some crops their refinement rather than shifting every later answer
    onto the wrong object.
    """
    import re

    found = re.findall(r"-?\d+", str(text)) if text is not None else []
    out = [_check(x, valid) for x in found[:n]]
    return out + [None] * (n - len(out))

VLM

VLM(model, api='openai', key=None, url=None, prompt=None, chunk=8, batch=None, grid=None, **kw)

Bases: Model

A hosted vision model asked which class a crop is.

These models describe an image well but do not give boxes, so use them with mode="crop". The primary keeps the box, the VLM only fixes the class.

Parameters:

Name Type Description Default
model str

model id, e.g. "gemini-3.1-flash-lite" or "gpt-4o-mini".

required
api str

"openai", "gemini", or "custom" with a url.

'openai'
key str | None

API key. Read from OPENAI_API_KEY or GEMINI_API_KEY if left out, depending on api.

None
url str | None

base url, for self-hosted or third party OpenAI-compatible servers. Gemini has one built in, so api="gemini" needs no url.

None
prompt str | None

format string overriding the default. It is given hint and menu.

None
chunk int

how many crops go in one request. 1 sends them one at a time.

8
batch str | None

format string overriding the batched prompt. It is given n, hints and menu.

None
grid str | None

format string overriding the collage prompt, used by mode="collage". It is given n (tiles in the collage), hint and menu.

None
kw Any

forwarded to the chat completion call, e.g. temperature, max_tokens.

{}

Never pass a key as a literal in code you commit. Put it in the environment.

Source code in vizor/models/api.py
def __init__(
    self,
    model: str,
    api: str = "openai",
    key: "str | None" = None,
    url: "str | None" = None,
    prompt: "str | None" = None,
    chunk: int = 8,
    batch: "str | None" = None,
    grid: "str | None" = None,
    **kw: Any,
):
    self.model = model
    self.api = api
    self.prompt = prompt or PROMPT
    self.batch_prompt = batch or BATCH
    self.grid_prompt = grid or GRID
    self.chunk = max(1, int(chunk))
    self.kw = {"temperature": 0, "max_tokens": 16, **kw}
    key = key or os.environ.get(ENV.get(api, ""))
    if key is None and url is None:
        raise ValueError(
            f"no API key: pass key= or set {ENV.get(api, 'the provider env var')}"
        )
    self.client = self._client(api, key, url or URL.get(api))

encode staticmethod

encode(img, quality=90)

BGR array to a base64 data url the chat APIs accept.

Source code in vizor/models/api.py
@staticmethod
def encode(img, quality=90):
    """BGR array to a base64 data url the chat APIs accept."""
    import base64

    ok, buf = cv2.imencode(".jpg", img, [cv2.IMWRITE_JPEG_QUALITY, quality])
    if not ok:
        raise ValueError("could not encode crop as jpeg")
    return "data:image/jpeg;base64," + base64.b64encode(buf).decode()

ask

ask(imgs, text, **kw)

Send one or more images with one question, return the reply as a string.

Several images go in a single message, each preceded by its number, so the model can answer about all of them at once.

Source code in vizor/models/api.py
def ask(self, imgs, text, **kw):
    """Send one or more images with one question, return the reply as a string.

    Several images go in a single message, each preceded by its number, so
    the model can answer about all of them at once.
    """
    if not isinstance(imgs, (list, tuple)):
        imgs = [imgs]
    content = []
    for i, img in enumerate(imgs, 1):
        if len(imgs) > 1:
            content.append({"type": "text", "text": f"Crop {i}:"})
        content.append({"type": "image_url", "image_url": {"url": self.encode(img)}})
    content.append({"type": "text", "text": text})
    reply = self.client.chat.completions.create(
        model=self.model,
        messages=[{"role": "user", "content": content}],
        **{**self.kw, **kw},
    )
    return reply.choices[0].message.content

name

name(crop, names=None, hint=None)

Ask the model which class the crop is. Returns a class id, or None if unsure.

Source code in vizor/models/api.py
def name(self, crop, names=None, hint=None):
    """Ask the model which class the crop is. Returns a class id, or None if unsure."""
    names = names if names is not None else self.names
    text = self.prompt.format(hint=hint, menu=menu(names))
    return parse_id(self.ask(crop, text), ids(names))

batch

batch(crops, names=None, hints=None)

Classify several crops, chunk of them per request.

One request carrying eight crops costs one round trip instead of eight, which is most of the wall clock in crop mode. The trade is that the model has to keep the order straight, and a reply with the wrong count loses the crops it did not cover rather than shifting the rest.

Source code in vizor/models/api.py
def batch(self, crops, names=None, hints=None):
    """Classify several crops, ``chunk`` of them per request.

    One request carrying eight crops costs one round trip instead of eight,
    which is most of the wall clock in crop mode. The trade is that the model
    has to keep the order straight, and a reply with the wrong count loses
    the crops it did not cover rather than shifting the rest.
    """
    names = names if names is not None else self.names
    hints = list(hints) if hints is not None else [None] * len(crops)
    valid, lines = ids(names), menu(names)
    out = []
    for i in range(0, len(crops), self.chunk):
        part, hint = crops[i:i + self.chunk], hints[i:i + self.chunk]
        if len(part) == 1:
            out.append(self.name(part[0], names, hint[0]))
            continue
        text = self.batch_prompt.format(
            n=len(part),
            hints=", ".join(f"{j}. {h!r}" for j, h in enumerate(hint, 1)),
            menu=lines,
        )
        # 16 tokens holds one id, not eight, so give the reply room to fit
        room = max(self.kw.get("max_tokens", 16), 8 * len(part))
        out += parse_ids(self.ask(part, text, max_tokens=room), len(part), valid)
    return out

grid

grid(collages, names=None, hints=None, tiles=1)

Ask about one collage per request. Returns a class id or None per collage.

chunk does not apply here. Several grids in one request would ask the model to keep the tiles and the grids straight at the same time, and collage mode already fires once per track rather than once per frame, so the round trips are not where the cost is.

Source code in vizor/models/api.py
def grid(self, collages, names=None, hints=None, tiles=1):
    """Ask about one collage per request. Returns a class id or None per collage.

    ``chunk`` does not apply here. Several grids in one request would ask the
    model to keep the tiles and the grids straight at the same time, and
    collage mode already fires once per track rather than once per frame, so
    the round trips are not where the cost is.
    """
    names = names if names is not None else self.names
    hints = list(hints) if hints is not None else [None] * len(collages)
    valid, lines = ids(names), menu(names)
    out = []
    for sheet, hint in zip(collages, hints):
        text = self.grid_prompt.format(n=tiles, hint=hint, menu=lines)
        out.append(parse_id(self.ask(sheet, text), valid))
    return out

HF

HF(model, device=None, dtype=None, prompt=None, grid=None, gen=None, **kw)

Bases: Model

A chat-style VLM loaded locally with transformers.

Like the hosted models it classifies crops but does not localise, so use it with mode="crop".

Parameters:

Name Type Description Default
model str

hub id or local path.

required
device str | None

"cuda", "cpu", or None to pick whatever is available.

None
dtype str | None

torch dtype, defaults to bfloat16 on GPU and float32 on CPU.

None
prompt str | None

format string overriding the default. Given hint and menu.

None
grid str | None

format string overriding the collage prompt, used by mode="collage". Given n (tiles in the collage), hint and menu.

None
gen dict[str, Any] | None

generation keyword arguments, e.g. max_new_tokens.

None
Source code in vizor/models/hf.py
def __init__(
    self,
    model: str,
    device: "str | None" = None,
    dtype: "str | None" = None,
    prompt: "str | None" = None,
    grid: "str | None" = None,
    gen: "dict[str, Any] | None" = None,
    **kw: Any,
):
    import torch
    from transformers import AutoProcessor

    self.device = device or ("cuda" if torch.cuda.is_available() else "cpu")
    self.dtype = dtype or (torch.bfloat16 if self.device == "cuda" else torch.float32)
    self.prompt = prompt or PROMPT
    self.grid_prompt = grid or GRID
    self.gen = {"max_new_tokens": 16, "do_sample": False, **(gen or {})}
    self.processor = AutoProcessor.from_pretrained(model, trust_remote_code=True)
    self.model = self._load(model, **kw)

ask

ask(img, question)

Send one image and one question, return the decoded reply.

Source code in vizor/models/hf.py
def ask(self, img, question):
    """Send one image and one question, return the decoded reply."""
    inputs = self.processor(text=self._text(question), images=[_pil(img)],
                            return_tensors="pt").to(self.device, self.dtype)
    out = self.model.generate(**inputs, **self.gen)
    out = out[:, inputs["input_ids"].shape[-1]:]  # drop the echoed prompt
    return self.processor.batch_decode(out, skip_special_tokens=True)[0].strip()

name

name(crop, names=None, hint=None)

Ask the model which class the crop is. Returns a class id, or None if unsure.

Source code in vizor/models/hf.py
def name(self, crop, names=None, hint=None):
    """Ask the model which class the crop is. Returns a class id, or None if unsure."""
    names = names if names is not None else self.names
    return parse_id(self.ask(crop, self.prompt.format(hint=hint, menu=menu(names))),
                    ids(names))

grid

grid(collages, names=None, hints=None, tiles=1)

Ask about each collage in its own forward pass, with the grid prompt.

Source code in vizor/models/hf.py
def grid(self, collages, names=None, hints=None, tiles=1):
    """Ask about each collage in its own forward pass, with the grid prompt."""
    names = names if names is not None else self.names
    hints = list(hints) if hints is not None else [None] * len(collages)
    valid, lines = ids(names), menu(names)
    return [parse_id(self.ask(s, self.grid_prompt.format(n=tiles, hint=h, menu=lines)), valid)
            for s, h in zip(collages, hints)]

Florence

Florence(model='microsoft/Florence-2-base-ft', names=None, task='<CAPTION_TO_PHRASE_GROUNDING>', **kw)

Bases: HF

Microsoft Florence-2, used as an open-vocabulary detector.

Florence grounds phrases to boxes, so it works in mode="full": it labels the whole frame in one pass and the refiner matches those boxes to the tracks by IoU. It gives no confidence, so every box comes back at 1.0.

Parameters:

Name Type Description Default
model str

hub id, e.g. "microsoft/Florence-2-base-ft".

'microsoft/Florence-2-base-ft'
names dict[int, str] | list[str] | None

class id to name mapping. Labels outside it are dropped.

None
task str

task token. "<CAPTION_TO_PHRASE_GROUNDING>" looks for the classes you list, "<OD>" reports whatever it finds.

'<CAPTION_TO_PHRASE_GROUNDING>'
Source code in vizor/models/hf.py
def __init__(
    self,
    model: str = "microsoft/Florence-2-base-ft",
    names: "dict[int, str] | list[str] | None" = None,
    task: str = "<CAPTION_TO_PHRASE_GROUNDING>",
    **kw: Any,
):
    kw.setdefault("gen", {"max_new_tokens": 1024, "num_beams": 3, "do_sample": False})
    super().__init__(model, **kw)
    self.names = names
    self.task = task

run

run(img, task, text='')

Run one Florence task and return its parsed dict.

Source code in vizor/models/hf.py
def run(self, img, task, text=""):
    """Run one Florence task and return its parsed dict."""
    pil = _pil(img)
    inputs = self.processor(text=task + text, images=pil, return_tensors="pt")
    inputs = inputs.to(self.device, self.dtype)
    out = self.model.generate(input_ids=inputs["input_ids"],
                              pixel_values=inputs["pixel_values"], **self.gen)
    raw = self.processor.batch_decode(out, skip_special_tokens=False)[0]
    return self.processor.post_process_generation(raw, task=task, image_size=pil.size)

find

find(img, names=None)

Ground the class names in a BGR frame. Every box comes back at confidence 1.0.

Source code in vizor/models/hf.py
def find(self, img, names=None):
    """Ground the class names in a BGR frame. Every box comes back at confidence 1.0."""
    lookup = self._lookup(names)
    task = self.task
    text = ", ".join(lookup) if task == "<CAPTION_TO_PHRASE_GROUNDING>" and lookup else ""
    out = self.run(img, task, text).get(task, {})
    rows = []
    for box, label in zip(out.get("bboxes", []), out.get("labels", [])):
        cls = lookup.get(str(label).lower().strip())
        if cls is None:
            continue
        rows.append([*box, 1.0, cls, -1])
    data = np.array(rows, np.float32) if rows else np.zeros((0, 7), np.float32)
    return Preds(data, names=names if names is not None else self.names)

name

name(crop, names=None, hint=None)

Class of the largest thing Florence finds in the crop.

Source code in vizor/models/hf.py
def name(self, crop, names=None, hint=None):
    """Class of the largest thing Florence finds in the crop."""
    preds = self.find(crop, names)
    if not len(preds):
        return None
    areas = (preds.boxes[:, 2] - preds.boxes[:, 0]) * (preds.boxes[:, 3] - preds.boxes[:, 1])
    return int(preds.cls[int(areas.argmax())])

grid

grid(collages, names=None, hints=None, tiles=1)

Largest thing Florence grounds in each collage. It has no chat prompt to word.

Source code in vizor/models/hf.py
def grid(self, collages, names=None, hints=None, tiles=1):
    """Largest thing Florence grounds in each collage. It has no chat prompt to word."""
    return [self.name(sheet, names) for sheet in collages]

Pkl

Pkl(file, cols=None, names=None)

Bases: Model

One saved list of per-frame boxes, replayed in order.

Parameters:

Name Type Description Default
file str | Path

pickle holding a list of (N, 6) or (N, 7) arrays or tensors.

required
cols list[int] | None

column order to reindex each frame into [x1, y1, x2, y2, conf, cls, id]. Pass Pkl.ULTRALYTICS for raw ultralytics output. None means the arrays are already in Vizor order.

None
names dict[int, str] | list[str] | None

class id to name mapping to hand downstream.

None

Frames are handed out in order on each track or find call, so the file must line up with the video you feed the pipeline.

Source code in vizor/models/pkl.py
def __init__(
    self,
    file: "str | Path",
    cols: "list[int] | None" = None,
    names: "dict[int, str] | list[str] | None" = None,
):
    with open(file, "rb") as f:
        self.frames = list(pickle.load(f))
    self.file = str(file)
    self.cols = cols
    self.names = names
    self.i = 0

reset

reset()

Rewind to the first frame.

Source code in vizor/models/pkl.py
def reset(self):
    """Rewind to the first frame."""
    self.i = 0

next

next()

The next frame as a float32 array, or an empty one past the end.

Source code in vizor/models/pkl.py
def next(self):
    """The next frame as a float32 array, or an empty one past the end."""
    if self.i >= len(self.frames):
        return np.zeros((0, 7), np.float32)
    data = np.asarray(self.frames[self.i], dtype=np.float32)
    self.i += 1
    if data.ndim == 1:
        data = data.reshape(0, 7) if not data.size else data.reshape(1, -1)
    if self.cols is not None and len(data):
        data = data[:, self.cols]
    return data

track

track(img=None)

The next saved frame as Tracks. img is ignored.

Source code in vizor/models/pkl.py
def track(self, img=None):
    """The next saved frame as Tracks. ``img`` is ignored."""
    return Tracks(self.next(), names=self.names)

find

find(img=None, names=None)

The next saved frame as Preds. img is ignored.

Source code in vizor/models/pkl.py
def find(self, img=None, names=None):
    """The next saved frame as Preds. ``img`` is ignored."""
    return Preds(self.next(), names=names or self.names)