mirror of
https://github.com/lancedb/lancedb.git
synced 2026-10-13 07:12:27 +00:00
Ten merges and renames, all in one direction: fewer pages, each covering a whole subject. Table absorbs schema evolution and versioning. Vector search absorbs multi-vector. Explain absorbs explain analyze. Reranking was seven pages -- an overview and six rerankers of a few dozen lines each -- and is now one. The vector index page absorbs quantization, which was only ever a parameter of it. Indexing moves to Manage, where the other catalog objects are. The operational half stays in Operate as one Indexing page, absorbing GPU indexing -- which was filed in sophon despite importing lancedb's snippets and being mostly open-source content with one Enterprise-badged section. Performance was a single page aggregating tips for four unrelated operations, which is hard to keep current. Each section now sits with the operation it is about: ingestion tips on Insert, index tips on Indexing, compaction tips on Compaction, query tips on Search. Data Types leaves Concepts. The type reference belongs in the SQL reference, which already described the same types from the query side; Table now carries a short summary that points there. The multimodal material it had absorbed is its own page again, since storing blobs is a task rather than a type definition. Query becomes Semantic Search, Branches becomes Branch, and the Enterprise Security page becomes Compliance. Redirects cover every moved URL, and 30 files were repointed at the merged anchors.
369 lines
16 KiB
Plaintext
369 lines
16 KiB
Plaintext
---
|
||
title: "Object Detection for AV Perception"
|
||
sidebarTitle: "Object Detection"
|
||
description: End-to-end fine-tuning of a Faster R-CNN object detector on curated dashcam slices, using LanceDB as the data backbone from raw frames to checkpoints.
|
||
icon: car
|
||
---
|
||
|
||
This example walks through fine-tuning an autonomous vehicle (AV) perception model on targeted failure-mode slices of [BDD100K](https://www.bdd100k.com/) — riders, nighttime pedestrians, and distant pedestrians — using LanceDB as a single multimodal table from raw JPEG bytes through to the PyTorch training loop.
|
||
|
||
The full pipeline lives in the [lancedb/training](https://github.com/lancedb/training/tree/main/object-detection) repository. This page focuses on the parts most relevant to training: defining curated splits as materialized views, loading them through the [`Permutation`](/training/) API, and pinning checkpoints to an exact data version.
|
||
|
||
## What you get {#what-you-get}
|
||
|
||
Fine-tuning Faster R-CNN ResNet50 FPN v2 for 10 epochs on each curated slice (batch size 64, AMP, A100), starting from the same COCO-pretrained checkpoint and evaluating on the matching validation view:
|
||
|
||
| Failure mode | Metric | Baseline (COCO) | Fine-tuned | Δ% |
|
||
|---|---|---|---|---|
|
||
| **Nighttime pedestrian** | mAP@0.5 | 0.4025 | **0.5192** | **+29.0%** |
|
||
| | Recall | 0.5923 | **0.7570** | **+27.8%** |
|
||
| **Rider** | mAP@0.5 | 0.5563 | **0.6676** | **+20.0%** |
|
||
| | Recall | 0.6788 | **0.7847** | **+15.6%** |
|
||
| **Distant pedestrian** | mAP@0.5 | 0.4746 | **0.5788** | **+22.0%** |
|
||
| | Recall | 0.6794 | **0.8024** | **+18.1%** |
|
||
|
||
No external data added — only training-distribution correction via SQL filters over a single Lance table. Each panel below shows the same frame with three overlaid predictions: **green** = ground truth · **red** = pretrained COCO baseline · **blue** = fine-tuned model.
|
||
|
||

|
||
|
||

|
||
|
||

|
||
|
||
The rest of the page walks through the pipeline that produced these checkpoints.
|
||
|
||
## The failure modes {#the-failure-modes}
|
||
|
||
A perception model fine-tuned on a generic dataset typically misses the long-tail scenarios that matter most in deployment. Three common failure modes drive this example:
|
||
|
||
| Failure mode | Curation signal |
|
||
|---|---|
|
||
| **Riders** (person on bike/motorcycle) | `has_rider = true` |
|
||
| **Nighttime pedestrians** | `timeofday = 'night' AND has_person = true` |
|
||
| **Distant pedestrians** | `has_person = true AND person_bbox_area_pct < 30.0` |
|
||
|
||
Each curated slice becomes a [materialized view](/feature-engineering/materialized-views) — a named, refreshable SQL filter over the source table — and the training script loads it by name. New footage flows in through `add()` → `backfill()` → `refresh()`; no manifests, no exports, no reshuffling on disk.
|
||
|
||
## 1\. Schema {#1-schema}
|
||
|
||
The source table holds raw image bytes alongside structured annotations. Bounding boxes are stored as a parallel list (one element per box) rather than a nested struct so they remain directly queryable with SQL.
|
||
|
||
```py Python icon=Python
|
||
import pyarrow as pa
|
||
|
||
BDD_SCHEMA = pa.schema([
|
||
pa.field("image_id", pa.string()),
|
||
pa.field("split", pa.string()), # "train" | "val"
|
||
pa.field("image_bytes", pa.large_binary()), # raw JPEG
|
||
pa.field("width", pa.int32()),
|
||
pa.field("height", pa.int32()),
|
||
|
||
# scene metadata
|
||
pa.field("weather", pa.string()),
|
||
pa.field("scene", pa.string()),
|
||
pa.field("timeofday", pa.string()),
|
||
|
||
# annotations — parallel lists, one element per box
|
||
pa.field("ann_categories", pa.list_(pa.string())),
|
||
pa.field("ann_bboxes", pa.list_(pa.list_(pa.float32()))),
|
||
pa.field("ann_occluded", pa.list_(pa.bool_())),
|
||
])
|
||
```
|
||
|
||
Ingestion streams `pa.RecordBatch`es of raw frames + annotations directly into a Lance table — no intermediate preprocessing job. The table can live on local disk, S3, GCS, or Azure; everything downstream (backfills, views, the training loader) opens it in place via `lancedb.connect("s3://...")` with no local copy step.
|
||
|
||
## 2\. Backfill curation features with Geneva {#2-backfill-curation-features-with-geneva}
|
||
|
||
Curation signals are added as columns on the same table using [computed columns](/feature-engineering/refresh-column). Backfills are incremental and checkpointed: re-running the command after new footage arrives only computes the new rows.
|
||
|
||
```py Python icon=Python
|
||
import pyarrow as pa
|
||
from geneva.transformer import udf
|
||
|
||
# Tier 1 — CPU, derived from annotations alone
|
||
@udf(data_type=pa.bool_(), input_columns=["ann_categories"])
|
||
def has_rider(ann_categories: list[str]) -> bool:
|
||
return "rider" in (ann_categories or [])
|
||
|
||
# Tier 2 — GPU, runs a Faster R-CNN to find the largest detected person
|
||
# as a percentage of frame area. <30% = a distant or small pedestrian,
|
||
# the hard case we want to upweight in training.
|
||
@udf(data_type=pa.float32(),
|
||
input_columns=["image_bytes", "width", "height"],
|
||
cuda=True, num_gpus=1)
|
||
class PersonBboxAreaPct:
|
||
def __init__(self):
|
||
self._model = None
|
||
|
||
def __call__(self, image_bytes, width, height):
|
||
# lazy model load — runs once per Ray worker, then reused
|
||
...
|
||
```
|
||
|
||
Run the backfill against the live table:
|
||
|
||
```py Python icon=Python
|
||
import geneva
|
||
|
||
gconn = geneva.connect("data/bdd100k/lancedb")
|
||
tbl = gconn.open_table("bdd100k")
|
||
|
||
tbl.add_columns({"has_rider": has_rider})
|
||
tbl.add_columns({"person_bbox_area_pct": PersonBboxAreaPct()})
|
||
|
||
with gconn.local_ray_context():
|
||
tbl.backfill("has_rider")
|
||
tbl.backfill("person_bbox_area_pct")
|
||
```
|
||
|
||
<Note>
|
||
Because the curation features are flat scalar columns on the same table, all four retrieval modes — SQL, full-text search, vector search, and SQL-filtered vector search — work directly without joins or exports. See [computed columns](/feature-engineering/refresh-column) for how a backfill fills only the rows that lack values.
|
||
</Note>
|
||
|
||
## 3\. Define training splits as materialized views {#3-define-training-splits-as-materialized-views}
|
||
|
||
A training split is a named SQL filter, not a CSV manifest. Each view stays in sync with the source table and bumps its `version` on every refresh — the link between a checkpoint and the exact data that produced it.
|
||
|
||
```py Python icon=Python
|
||
import geneva
|
||
|
||
gconn = geneva.connect("data/bdd100k/lancedb")
|
||
gtbl = gconn.open_table("bdd100k")
|
||
|
||
VIEWS = {
|
||
"bdd100k_rider_train":
|
||
"has_rider = true AND split = 'train'",
|
||
"bdd100k_rider_val":
|
||
"has_rider = true AND split = 'val'",
|
||
"bdd100k_nighttime_person_train":
|
||
"timeofday = 'night' AND has_person = true AND split = 'train'",
|
||
"bdd100k_nighttime_person_val":
|
||
"timeofday = 'night' AND has_person = true AND split = 'val'",
|
||
"bdd100k_distant_person_train":
|
||
"has_person = true AND person_bbox_area_pct < 30.0 AND split = 'train'",
|
||
"bdd100k_distant_person_val":
|
||
"has_person = true AND person_bbox_area_pct < 30.0 AND split = 'val'",
|
||
}
|
||
|
||
with gconn.local_ray_context():
|
||
for name, sql_filter in VIEWS.items():
|
||
query = gtbl.search().where(sql_filter)
|
||
mv = gconn.create_materialized_view(name, query)
|
||
mv.refresh()
|
||
print(f"[{name}] {mv.count_rows()} rows (version {mv.version})")
|
||
```
|
||
|
||
## 4\. PyTorch DataLoader via the Permutation API {#4-pytorch-dataloader-via-the-permutation-api}
|
||
|
||
The training script doesn't know about the filter — it opens a view by name and reads through the [`Permutation`](/training/) API. Each DataLoader worker reopens its own connection lazily, reads Arrow batches directly from Lance (zero-copy, no intermediate file format), and the collate function decodes the whole batch in one pass. `Permutation` provides random-access indexing over the table, so shuffling is a cheap pointer rewrite rather than a full-dataset shuffle on disk.
|
||
|
||
```py Python icon=Python
|
||
import lancedb
|
||
import torch
|
||
import torchvision.io as tio
|
||
from lancedb.permutation import Permutation
|
||
|
||
DETECTION_COLS = ["image_bytes", "ann_categories", "ann_bboxes"]
|
||
|
||
class LanceDetectionDataset(torch.utils.data.Dataset):
|
||
def __init__(self, uri: str, table_name: str):
|
||
self.uri, self.table_name = uri, table_name
|
||
self._perm = None
|
||
self.length = len(lancedb.connect(uri).open_table(table_name))
|
||
|
||
def __len__(self):
|
||
return self.length
|
||
|
||
def __getstate__(self):
|
||
# Permutation holds Rust async state — zero it so each worker reopens
|
||
state = self.__dict__.copy()
|
||
state["_perm"] = None
|
||
return state
|
||
|
||
def _ensure_open(self):
|
||
if self._perm is None:
|
||
tbl = lancedb.connect(self.uri).open_table(self.table_name)
|
||
self._perm = (
|
||
Permutation.identity(tbl)
|
||
.select_columns(DETECTION_COLS)
|
||
.with_format("arrow") # zero-copy
|
||
)
|
||
|
||
def __getitems__(self, indices: list[int]):
|
||
self._ensure_open()
|
||
return self._perm.__getitems__(indices)
|
||
```
|
||
|
||
The collate function decodes JPEG bytes and converts BDD category strings into COCO class IDs (so the comparison against the pretrained checkpoint is valid):
|
||
|
||
```py Python icon=Python
|
||
BDD_LABEL_MAP = {
|
||
"person": 1, "rider": 1, "bicycle": 2, "car": 3, "motorcycle": 4,
|
||
"bus": 6, "train": 7, "truck": 8, "traffic light": 10,
|
||
}
|
||
|
||
|
||
def detection_collate(batch):
|
||
images, targets = [], []
|
||
for raw, cats, bboxes in zip(
|
||
batch.column("image_bytes").to_pylist(),
|
||
batch.column("ann_categories").to_pylist(),
|
||
batch.column("ann_bboxes").to_pylist(),
|
||
):
|
||
buf = torch.frombuffer(bytearray(raw), dtype=torch.uint8)
|
||
images.append(tio.decode_image(buf, tio.ImageReadMode.RGB).float() / 255.0)
|
||
|
||
valid_boxes, valid_labels = [], []
|
||
for cat, box in zip(cats or [], bboxes or []):
|
||
lid = BDD_LABEL_MAP.get(cat)
|
||
if lid is None or box[2] <= box[0] or box[3] <= box[1]:
|
||
continue
|
||
valid_boxes.append(box)
|
||
valid_labels.append(lid)
|
||
targets.append({
|
||
"boxes": torch.tensor(valid_boxes or [], dtype=torch.float32).reshape(-1, 4),
|
||
"labels": torch.tensor(valid_labels or [], dtype=torch.int64),
|
||
})
|
||
return images, targets
|
||
```
|
||
|
||
Wire it into a standard `torch.utils.data.DataLoader`:
|
||
|
||
```py Python icon=Python
|
||
def make_loader(uri, table_name, batch_size=64, num_workers=8, shuffle=False):
|
||
dataset = LanceDetectionDataset(uri, table_name)
|
||
sampler = torch.utils.data.RandomSampler(dataset) if shuffle else None
|
||
return torch.utils.data.DataLoader(
|
||
dataset,
|
||
batch_size=batch_size,
|
||
sampler=sampler,
|
||
num_workers=num_workers,
|
||
collate_fn=detection_collate,
|
||
pin_memory=torch.cuda.is_available(),
|
||
persistent_workers=(num_workers > 0),
|
||
multiprocessing_context="spawn" if num_workers > 0 else None,
|
||
)
|
||
```
|
||
|
||
<Note>
|
||
`with_format("arrow")` keeps batches as zero-copy `pa.RecordBatch`es — no per-row Python boxing, no pickling between worker and main. Each DataLoader worker reopens its own `Permutation` after fork (the Rust async handle is cleared in `__getstate__`), so reads scale with `num_workers` and stream straight from the underlying object store. JPEG decode overlaps with GPU compute via `pin_memory` + `prefetch_factor`, which is what keeps the loader from becoming the bottleneck on a fast GPU.
|
||
</Note>
|
||
|
||
## 5\. Fine-tune Faster R-CNN {#5-fine-tune-faster-r-cnn}
|
||
|
||
The training loop is plain PyTorch — the Lance integration ends at the loader. Mixed precision is enabled on CUDA for ~2× speedup on Ampere GPUs.
|
||
|
||
```py Python icon=Python
|
||
import time
|
||
import torch
|
||
from torchvision.models.detection import (
|
||
fasterrcnn_resnet50_fpn_v2, FasterRCNN_ResNet50_FPN_V2_Weights,
|
||
)
|
||
|
||
device = torch.device("cuda" if torch.cuda.is_available() else "cpu")
|
||
use_amp = device.type == "cuda"
|
||
|
||
# COCO pretrained weights — head left intact since BDD uses a subset of COCO IDs
|
||
model = fasterrcnn_resnet50_fpn_v2(
|
||
weights=FasterRCNN_ResNet50_FPN_V2_Weights.COCO_V1
|
||
).to(device)
|
||
|
||
train_loader = make_loader("data/bdd100k/lancedb",
|
||
"bdd100k_rider_train",
|
||
batch_size=64, num_workers=14, shuffle=True)
|
||
val_loader = make_loader("data/bdd100k/lancedb",
|
||
"bdd100k_rider_val",
|
||
batch_size=64, num_workers=14)
|
||
|
||
optimizer = torch.optim.SGD(
|
||
[p for p in model.parameters() if p.requires_grad],
|
||
lr=0.04, momentum=0.9, weight_decay=1e-4,
|
||
)
|
||
scheduler = torch.optim.lr_scheduler.StepLR(optimizer, step_size=3, gamma=0.1)
|
||
scaler = torch.cuda.amp.GradScaler() if use_amp else None
|
||
|
||
for epoch in range(1, 11):
|
||
model.train()
|
||
t0 = time.time()
|
||
for images, targets in train_loader:
|
||
images = [img.to(device) for img in images]
|
||
targets = [{k: v.to(device) for k, v in t.items()} for t in targets]
|
||
if all(t["labels"].numel() == 0 for t in targets):
|
||
continue
|
||
|
||
with torch.cuda.amp.autocast(enabled=use_amp):
|
||
losses = sum(model(images, targets).values())
|
||
|
||
optimizer.zero_grad()
|
||
if use_amp:
|
||
scaler.scale(losses).backward()
|
||
scaler.unscale_(optimizer)
|
||
torch.nn.utils.clip_grad_norm_(model.parameters(), 10.0)
|
||
scaler.step(optimizer)
|
||
scaler.update()
|
||
else:
|
||
losses.backward()
|
||
torch.nn.utils.clip_grad_norm_(model.parameters(), 10.0)
|
||
optimizer.step()
|
||
|
||
scheduler.step()
|
||
print(f"epoch {epoch} ({time.time() - t0:.1f}s)")
|
||
```
|
||
|
||
## 6\. Pin the checkpoint to a data version {#6-pin-the-checkpoint-to-a-data-version}
|
||
|
||
Every Lance table — including a materialized view — exposes a monotonically increasing `version`. Logging it next to the weights gives a permanent, deterministic link between a checkpoint and the exact data snapshot that produced it.
|
||
|
||
```py Python icon=Python
|
||
import json
|
||
from pathlib import Path
|
||
|
||
train_tbl = lancedb.connect("data/bdd100k/lancedb").open_table("bdd100k_rider_train")
|
||
|
||
out = Path("checkpoints/rider")
|
||
out.mkdir(parents=True, exist_ok=True)
|
||
torch.save(model.state_dict(), out / "fasterrcnn_bdd_finetuned.pt")
|
||
with open(out / "metadata.json", "w") as f:
|
||
json.dump({
|
||
"train_table": train_tbl.name,
|
||
"table_version": train_tbl.version,
|
||
"row_count": len(train_tbl),
|
||
}, f, indent=2)
|
||
```
|
||
|
||
To reproduce a run, [time-travel](/management/table#versioning) the view to the recorded version before opening the loader:
|
||
|
||
```py Python icon=Python
|
||
tbl = lancedb.connect("data/bdd100k/lancedb").open_table("bdd100k_rider_train")
|
||
tbl.checkout(version=7) # exact snapshot the checkpoint was trained on
|
||
```
|
||
|
||
## 7\. Continuous updates {#7-continuous-updates}
|
||
|
||
When new footage arrives, the same three calls update every downstream view — no view definitions change, no training-script edits required:
|
||
|
||
```py Python icon=Python
|
||
# 1. ingest the new footage into the source table
|
||
table.add(new_record_batches)
|
||
|
||
# 2. backfill computes only the new rows (incremental, checkpointed)
|
||
with gconn.local_ray_context():
|
||
tbl.backfill("has_rider")
|
||
tbl.backfill("person_bbox_area_pct")
|
||
|
||
# 3. refresh appends qualifying new rows to every materialized view
|
||
for view_name in gconn.table_names():
|
||
if view_name == "bdd100k":
|
||
continue
|
||
mv = gconn.open_table(view_name)
|
||
before = mv.count_rows()
|
||
mv.refresh()
|
||
print(f"[{view_name}] {before} → {mv.count_rows()} rows (version {mv.version})")
|
||
```
|
||
|
||
The next training run picks up the new data automatically — and pins itself to the new `version`.
|
||
|
||
## Full source {#full-source}
|
||
|
||
The complete code, including a synthetic-data mode for pipeline verification (`--synthetic 500`), GPU UDFs for CLIP embeddings and dHash deduplication, and the EDA notebook, is in this [GitHub repository](https://github.com/lancedb/training/tree/main/object-detection).
|