VLM Frame Annotation Pipeline¶
What this is¶
vlm_annotation is a VideoAnnotator pipeline that samples frames from a video
on a fixed cadence and classifies/describes each one using a locally-hosted
vision-language model (VLM), served by Ollama, driven
by a user-supplied text prompt. Every sample point is one independent,
stateless model call — no conversation history carries over between frames,
so one prediction can never be biased by a previous one.
It implements the "Local LLM/VLM Backend" item from
roadmap_v1.6.0.md Phase 2, brought forward and driven
by a real user: Irene's PhD research project on mother-infant touch detection
(mother-infant-touch-detection repo — a sibling project, not part of this
one). That project's own ad hoc research scripts
(core/inference.py, core/context_inference.py) are what this pipeline is
ported from — see "Architecture" below for the mapping.
Status¶
| Phase | What | Status |
|---|---|---|
| 1 | Pipeline itself (this doc) | Done — merged into v1.5.0 |
| 2 | End-to-end validation | Done — see "Step-by-step: test it yourself" |
| 3 | Job-creation UX (prompt textarea, sampling-mode picker) | Done — see below |
| 4 | Full research-review UI (ELAN ground truth, cross-prompt comparison, failure-mode buckets) | Foundation done, rest planned — lives in the video-annotation-viewer repo |
Phase 3 landed in two repos:
- This repo: a new
GET /api/v1/pipelines/{name}/schemaendpoint, plus two additivePipelineConfigFieldYAML hints (widget: textarea,enum: [...]) thatvlm_annotation.yamlnow uses for itspromptandsampling_modefields. Turned out to be a broader fix than just this pipeline: the viewer's job-creation form was already coded to call this exact endpoint and only fell back to a blank parameter list on 404 — since the endpoint never existed, every pipeline's Configure step showed "No configurable parameters," not just this one. Every pipeline now gets a real form for free. video-annotation-viewerrepo: a'text'parameter type +Textareacase (DynamicPipelineParameters.tsx), and wiringusePipelineSchema/pipelineSchemaQueryOptions(already written, previously unused) intoNewJob.tsx's Configure step so it actually fetches and merges each selected pipeline's real schema. Multi-video batch submission with one shared config — the other thing this phase needed — already existed (NewJob.tsx's file input already takes multiple files and loopssubmitJobper file with the same config).
Verified live in a browser against a real server: vlm_annotation's
Configure step renders prompt as a pre-filled multi-line textarea and
sampling_mode as a dropdown, with no console errors and no regression for
pipelines without either hint.
Phase 4 is tracked in this document's "Full plan" section below for
continuity, but the actual work happens in the video-annotation-viewer
repo, not here.
Architecture¶
src/videoannotator/pipelines/vlm_annotation/
├── __init__.py
├── vlm_pipeline.py VLMAnnotationPipeline(BasePipeline) — sampling + orchestration
└── ollama_client.py OllamaVLMClient — stateless client.chat() wrapper, retry/backoff
src/videoannotator/registry/metadata/vlm_annotation.yaml config_schema, requires_extras: [llm]
- Model client (
ollama_client.py): wraps theollamaPython package'sClient.chat()directly — the same library the research repo already uses in production — rather than a hand-rolled OpenAI-compatible HTTP client.roadmap_v1.6.0.mdoriginally proposed "one connector against the OpenAI-compatible/v1/chat/completionscontract" so the same code could also drive allama.cppserver; that's a deliberate deviation for now, not an oversight — reusing the provenollamaclient de-risked getting a working pipeline landed quickly.backends: [ollama]in the pipeline metadata leaves room for anopenai_compatiblebackend alongside it later without redesigning the pipeline. - Sampling modes (
vlm_pipeline.py::process), both supported from day one because the research repo's own roadmap treats the choice between them as a still-open research question (seedocs/research_roadmap.md§Phase 4 Decision D in the touch-detection repo): single_frame— one image per sample point. Ported fromcore/inference.py.frame_burst— a window of frames (burst_offsets, default[-2,-1,0,1,2], in units offrame_interval_sec) attached to one chat call as multipleimageslist entries. Ported fromcore/context_inference.py. This is not true video/temporal input — it's several still JPEGs in one message, exactly like the research repo's own "3-image grid" mode. Genuine video-native input is out of scope here, same as it is (still unimplemented, explicitly conditional) in the research repo.- Output: COCO-shaped annotation dicts (
create_coco_annotation), one per sample point, withlabel(parsed from the model's response),reasoning(thinking text ifthink: true, else the raw response),raw_response,timestamp_sec,frame_number,sampling_mode,context_frame_offsets/context_frame_numbers(burst mode only), and full provenance per record:model,backend,base_url,prompt, plus timing/token stats (total_time,load_time,prompt_tokens,resp_tokens,tokens_per_sec). Written to{output_dir}/{video_id}_vlm_annotation.jsonand validated withpycocotoolson write, same asscene_detection.
Configuration reference¶
All fields have defaults — nothing is required. Set via job config keyed
by pipeline name: {"vlm_annotation": {"prompt": "...", ...}}.
| Field | Type | Default | Notes |
|---|---|---|---|
prompt |
string | Irene's maternal_perspective touch-detection prompt |
Applied identically and independently to every sample point |
base_url |
string | "" (server's OLLAMA_BASE_URL, else http://127.0.0.1:11434) |
Ollama server. The devcontainer and docker-compose.yml set OLLAMA_BASE_URL=http://host.docker.internal:11434 so a server in a container reaches Ollama on the host |
model |
string | qwen3.5:9b |
Must already be pulled (ollama pull <name>) — nothing is auto-downloaded |
sampling_mode |
string | single_frame |
single_frame or frame_burst |
frame_interval_sec |
float | 5.0 |
Seconds between sample points |
burst_offsets |
list[int] | [-2,-1,0,1,2] |
frame_burst only, in units of frame_interval_sec |
think |
boolean | false |
Ollama "thinking" mode — ~50x slower, diagnostic use only |
request_timeout_sec |
integer | 240 |
Per-request timeout |
keep_alive |
string | "2h" |
How long Ollama keeps the model loaded between calls |
Known gaps / sharp edges¶
- Two independent job-execution engines exist in VideoAnnotator today
(pre-existing, not introduced by this pipeline —
roadmap_v1.6.0.mdalready commits to consolidating them): videoannotator serverautomatically starts an in-process polling loop on startup (api/background_tasks.py) that processes pending jobs viaapi/job_processor.py.- The separate
videoannotator workerCLI command starts a second, independent polling loop (worker/job_processor.py) viabatch/batch_orchestrator.py.
Don't run both against the same job database at once — they'd both try
to claim the same pending jobs. For everything below, videoannotator
server alone is enough; no separate worker process is needed.
- api/job_processor.py previously didn't pass job config into the pipeline
constructor at all (fixed as part of this work — see the commit that added
this pipeline). If you see a pipeline ignoring its submitted config on a
future VideoAnnotator checkout, check that fix is still in place.
- The CLI's job submit / job download-annotations commands don't support
passing an API key — they'll 401 against a server with auth enabled. For
local testing, run the server with AUTH_REQUIRED=false (see below). This
is a pre-existing CLI gap, not specific to this pipeline.
- config_schema's prompt field has no "long text" widget hint yet, so a
UI building a form from it today would render a one-line input, not a
textarea. That's Phase 3 work (see "Full plan").
- videoannotator diagnose doesn't yet detect a reachable Ollama server the
way it detects GPU availability — a roadmap_v1.6.0.md item that's still
open.
Step-by-step: test it yourself¶
Prerequisites¶
- Ollama installed and a vision model pulled:
ollama pull qwen3.5:9b(or edit themodelconfig field to whatever you have —ollama listshows what's already pulled). - VideoAnnotator installed with the
llmextra:pip install -e ".[llm]" # or: uv pip install -e ".[llm]"
Option A — fastest: direct Python smoke test (no server, no auth)¶
from videoannotator.pipelines.vlm_annotation import VLMAnnotationPipeline
pipeline = VLMAnnotationPipeline({
"model": "qwen3.5:9b",
"sampling_mode": "single_frame", # or "frame_burst"
"frame_interval_sec": 5.0,
})
annotations = pipeline.process(
video_path="path/to/your/video.mp4",
output_dir="path/to/output_dir",
)
for ann in annotations:
print(ann["timestamp_sec"], ann["label"], ann["raw_response"])
pipeline.cleanup()
This is the quickest way to check a prompt or model change — it talks
straight to Ollama, no API server, no database, no auth. Output also lands as
COCO JSON at {output_dir}/{video_id}_vlm_annotation.json.
Option B — the realistic path: CLI + API server¶
This is closer to what the eventual viewer UI will do.
# 1. Start the server. AUTH_REQUIRED=false avoids the CLI's lack of
# API-key support (see "Known gaps" above) — local testing only.
AUTH_REQUIRED=false videoannotator server --port 18011
# 2. In another terminal: write a config file (keyed by pipeline name)
cat > vlm_config.json <<'EOF'
{
"vlm_annotation": {
"model": "qwen3.5:9b",
"sampling_mode": "single_frame",
"frame_interval_sec": 5.0
}
}
EOF
# 3. Submit a job — the server's background poller (started automatically
# with the server, see "Known gaps") picks it up within a few seconds.
videoannotator job submit path/to/video.mp4 \
--pipelines vlm_annotation \
--config vlm_config.json \
--server http://127.0.0.1:18011
# 4. Poll status (or just wait ~5-10s for a short video)
videoannotator job status <job-id> --server http://127.0.0.1:18011
# 5. See a summary
videoannotator job results <job-id> --server http://127.0.0.1:18011
Output JSON lands at storage/jobs/<job-id>/<video-stem>_output/<video-stem>_vlm_annotation.json
under the server's working directory.
Both paths were run against a live ollama serve + qwen3.5:9b as part of
landing this pipeline, in both single_frame and frame_burst modes —
Option B additionally confirmed the job-config-passing fix (see "Known gaps")
actually takes effect end-to-end through the API.
Phase 4 progress¶
The foundation landed: a VLMFrameAnnotation type, a parser
(src/lib/parsers/vlm.ts), detection wired into merger.ts (placed ahead
of the generic COCO-format checks, since this pipeline's export uses the
same COCO info/annotations envelope as person_tracking/scene_detection — a
"reasoning"+"sampling_mode" signature check claims it first), a
point-marker Timeline track, and a VlmAnnotationPanel showing the current
label + expandable reasoning synced to playback time. Verified in a real
browser against real pipeline output — labels render correctly, timeline
markers are clickable, and the panel updates correctly on scrub.
Building this surfaced a real, separate bug: the viewer has four
independent places that each re-derive a file's pipeline type from its
JSON content (merger.ts, fileUtils.ts, and two hardcoded arrays inside
FileUploader.tsx), not one. All four needed a vlm_annotation case
added; fileUtils.ts's version turned out not to even handle the
COCO-wrapped scene_detection export correctly either (pre-existing, left
alone — out of scope here, but worth knowing about before trusting any one
of those four in isolation).
ELAN ground-truth comparison also now landed (video-annotation-viewer
commit 09991c1, branch feature/vlm-annotation-support): .eaf parsing
(ported from 00_preprocessing/parse_eaf_to_timeline.py), a timeline
segment track colored by tier, and a live agree/disagree indicator
comparing the VLM prediction against the ELAN four-way category at the
current time. Verified with a synthetic .eaf file covering both the
agree and disagree cases against real pipeline output.
Still not built: lap-state stratification, a cross-job "compare prompts" view, client-side failure-mode bucketing, and the human-in-the-loop agree/disagree review flow — see "Full plan" below for what each of those involves.
Full plan¶
The full four-phase plan this pipeline is part of:
- Backend pipeline (this doc,
VideoAnnotatorrepo) — done. - End-to-end validation — done (see above).
- Job-creation UX (
video-annotation-viewerrepo) — a prompt textarea (needs amultiline/textwidget hint added toPipelineConfigFieldand threaded through to the frontend'sDynamicPipelineParameters.tsx), a sampling-mode picker (works today via the existingenum→Selectpattern once the schema exposes it that way), and — if it doesn't already exist — a way to submit one config across a whole folder of videos at once, since Irene's real workflow is "one prompt, whole corpus." - Full research-review UI (
video-annotation-viewerrepo) — the largest remaining phase: a newVLMFrameAnnotationannotation type + parser, a timeline track + expandable-reasoning overlay component, an ELAN ground-truth.eafparser (ported from00_preprocessing/parse_eaf_to_timeline.pyin the touch-detection repo), lap-state stratification, a cross-job "compare prompts" view (since a VideoAnnotator job is one-video-one-config, comparing prompts means diffing two jobs' results), client-side failure-mode bucketing, and a human-in-the-loop agree/disagree + notes review flow. This is intended to reach parity with the touch-detection repo's existing standalonetools/viewer/viewer.html, generalized to work for any prompt/job rather than a hardcoded set of experiment runs.
Phases 3–4 are scoped in detail (file:line references into
video-annotation-viewer) in the planning conversation that produced this
pipeline — ask for that plan to be re-surfaced if picking this up fresh.
References¶
roadmap_v1.6.0.md— the release roadmap this pipeline's Phase 2 item belongs to.mother-infant-touch-detectionrepo (sibling project) — the research codebase this pipeline is ported from:core/inference.py,core/context_inference.py,docs/research_roadmap.md,tools/viewer/viewer.html..specify/memory/constitution.md— Principle I (Local-First, explicitly names "a self-hosted Ollama instance" as an allowed backend) and Principle III (Provenance & Reproducibility, why every annotation carries model/backend/base_url/prompt) are the two most relevant here.