examples/python/droid_semantic_search/README.md
Find moments in robot demonstrations by describing them in plain language, and jump straight to the matching frame in the Rerun viewer.
<picture> <source media="(max-width: 480px)" srcset="https://static.rerun.io/droid_semantic_search/fd265e110c9d05c9fdd018c68dd83561aa836c52/480w.png"> <source media="(max-width: 768px)" srcset="https://static.rerun.io/droid_semantic_search/fd265e110c9d05c9fdd018c68dd83561aa836c52/768w.png"> <source media="(max-width: 1024px)" srcset="https://static.rerun.io/droid_semantic_search/fd265e110c9d05c9fdd018c68dd83561aa836c52/1024w.png"> <source media="(max-width: 1200px)" srcset="https://static.rerun.io/droid_semantic_search/fd265e110c9d05c9fdd018c68dd83561aa836c52/1200w.png"> </picture>This pulls frame embeddings from a Rerun dataset, indexes them in an external vector store, and resolves text queries back into deep-links that open the relevant frames in the viewer.
It ships two interchangeable backends — LanceDB (default) and Qdrant, both fully local on disk and selected with --backend.
They're worked examples of one pattern — embeddings out of Rerun, search in your vector database of choice, deep-links back into the viewer — that applies to any vector store.
DataSource / Field / VideoFrameDecoder) for streaming and decoding H.264 frames on demand.segment_url deep-links that focus the viewer on a specific frame.Three scripts:
prepare_dataset.py — registers a few DROID episodes to your local catalog as a dataset (using the episodes bundled in the repo, or downloading them from the Hugging Face Hub when those aren't present). This is the data the other two scripts read.
ingest.py — builds the index. For each camera it auto-detects the embedding source:
/camera/{role}/embedding column (DROID registered with --create-embeddings), it reads those vectors directly via a DataFusion query — no model, no video decoding.VideoStream through the dataloader, decodes frames, and embeds them with SigLIP-2.Either way it writes (segment_id, camera, timestamp_ms, vector) rows into the selected vector store: LanceDB by default, or Qdrant with --backend qdrant.
search.py — embeds your example image or text prompt with the SigLIP-2 encoder, runs a vector search over that store (pass the same --backend), prints the ranked matches, and opens the best one in the viewer.
Both scripts reach the store only through a small VectorStore interface in vector_store.py, so picking a backend is one --backend choice and adding one is a single subclass.
Each backend keeps its index in its own directory (./droid_lancedb vs ./droid_qdrant, override with --db-path), so the --backend you pass to search.py must match the one ingest.py wrote.
This example has its own uv project, separate from the workspace .venv, because it needs the experimental rerun-sdk[dataloader] extras plus heavy ML deps (transformers, lancedb).
Standalone (sparse-checkout of just this directory, no local Rerun build):
uv sync --no-sources --no-dev
Monorepo dev (full repo checkout, editable local rerun-sdk):
cd examples/python/droid_semantic_search
RERUN_ALLOW_MISSING_BIN=1 uv sync
uv pip install ../../../rerun_py/rerun_dev_fixup
The second command installs the .pth shim that points import rerun (and the rerun CLI) at the in-repo editable source tree.
It's a separate uv pip install rather than a dev-group dependency because uv resolves all dependency groups unconditionally, so a path-only package in pyproject.toml would break the standalone --no-sources resolution above.
Then either source .venv/bin/activate or prefix subsequent commands with uv run.
This example reads data from a local open-source Rerun catalog server. In a separate terminal, start one:
rerun server
This serves a catalog at rerun+http://127.0.0.1:51234 — the default the scripts use.
Leave it running; the steps below connect to it.
Registers a few DROID episodes to the catalog as droid:sample:
uv run python prepare_dataset.py
By default it auto-selects the source:
tests/assets/rrd/sample_5 (via git-LFS), so there's nothing to download. If those are un-pulled LFS pointers, run git lfs install && git lfs pull first.rerun/droid_sample on the Hugging Face Hub into ./data.Useful flags:
--source bundled|huggingface to force a source (default auto).--num-episodes N to register more (or fewer) episodes; 0 for all (the full Hub dataset is ~3.3 GB).--dataset-name to register under a different name (pass the same name to ingest.py/search.py).--no-optimize to register episodes as-is (see the video decode yield note below).--catalog-url "" to skip registration.Index a handful of segments (the exterior camera works well for scene-level search):
uv run python ingest.py --num-segments 15 --cameras ext1
These sample episodes ship video only (no pre-computed embeddings), so ingest.py takes the compute path:
the first run downloads the SigLIP-2 model (a few hundred MB) and then decodes and embeds frames — the slow step.
Start with a small --num-segments to keep it quick; raise it once you've seen it work.
Search by text and open the best hit in the viewer:
uv run python search.py "an open drawer full of tools" --top-k 5
Or search by example image instead of text (same vector space):
uv run python search.py --image ./query.jpg --top-k 5
Both scripts default to LanceDB; pass --backend qdrant to use Qdrant instead.
Give the same --backend to ingest.py and search.py, since each writes to its own directory:
uv run python ingest.py --backend qdrant --num-segments 15 --cameras ext1
uv run python search.py --backend qdrant "an open drawer full of tools" --top-k 5
Queries that discriminate well on DROID describe concrete, visible objects/scenes, e.g. "a pink flower", "a cardboard box", "a white plastic bag", "a robot arm over an empty table".
Run ingest.py --help and search.py --help for the full flag list — index multiple cameras, change the sampling rate, widen the time selection around a hit, and more.
--image <path>, by an example frame. SigLIP-2 puts text and image features in one space, so image-to-image search reuses the exact same index and ranking — only the query encoder differs.VectorStore interface in vector_store.py — the same write-then-query flow drops onto any vector database, so adding a third is a single subclass.padding="max_length", max_length=64. With dynamic padding the text embeddings are malformed and text→image retrieval collapses onto a single "hub" frame that wins every query.VideoStream:is_keyframe markers the decoder needs to seek, so without them only ~25 % of sampled frames decode.
prepare_dataset.py derives the markers up front (via optimize), which brings yield to ~100 %.
Optimized copies land in ./optimized; the originals are untouched.
Skip it with --no-optimize if you'd rather register the raw episodes.prepare_dataset.py — register DROID sample episodes (bundled or downloaded) to the catalog.ingest.py — build the vector index from the dataset.search.py — query the index and open results in the viewer.vector_store.py — the VectorStore interface, with LanceDB and Qdrant backends.embeddings.py — SigLIP-2 helpers (adapted from the DROID loader's embedding_util.py).