Skip to content
Draft
Show file tree
Hide file tree
Changes from 15 commits
Commits
Show all changes
28 commits
Select commit Hold shift + click to select a range
e2cbb3e
init generalizing refactor
Aug 20, 2026
32e9763
improve perception stack; test across different sourced
Aug 21, 2026
35c65c8
refactor localize to memory transforms
Aug 22, 2026
37d22ae
impl: (a) All instances per query. (b) Global point cloud per instance
Aug 22, 2026
eef787d
filter before decode in _vector_search
Aug 24, 2026
fe42124
support plane cached per pose cell
Aug 24, 2026
840463a
OWLv2 score-row cache keyed by (frame ts, label), batched misses
Aug 24, 2026
131e03d
add persistent identity groups, `identity_store.py`
Aug 24, 2026
909e6ac
make fuse to compute the exact all-time mean
Aug 25, 2026
e717a42
detection small fixes
Aug 25, 2026
04706c5
project images; tool_localize update
Aug 25, 2026
429c8a8
improve detection
Aug 26, 2026
6935a68
in clout_at: replace the broadcast with a boolean covered-grid over t…
Aug 27, 2026
26e993f
smal improv to lattice_quantum foo
Aug 27, 2026
ff031de
ship go2_short.db.rig.json inside the go2_short LFS archive
Aug 27, 2026
8a7e58c
[autofix.ci] apply automated fixes
autofix-ci[bot] Aug 27, 2026
3d151d5
fix mypy
Aug 28, 2026
55355cd
move to mem api
Aug 30, 2026
091537d
test(detection): re-record OBB expectations for the corrected projection
Aug 30, 2026
533e0bc
[autofix.ci] apply automated fixes
autofix-ci[bot] Aug 30, 2026
96ce3a6
add LocalizePolicy for the localize caller. The caller can tune any l…
Aug 31, 2026
57d11cb
enhanse rig.py
Aug 31, 2026
b32dd57
add localize live blueprint
Sep 1, 2026
3e10486
profile memory for rerun
Sep 1, 2026
886adbb
make mcp call caller dependent; preserve the default 30 sec rule
Sep 1, 2026
fcd5b70
remove inventory api
Sep 3, 2026
de79d19
refactor the stack
Sep 3, 2026
00f0c66
fix mypy
Sep 3, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions data/.lfs/go2_short.db.tar.gz
Git LFS file not shown
15 changes: 9 additions & 6 deletions dimos/memory/backend.py
Original file line number Diff line number Diff line change
Expand Up @@ -207,13 +207,16 @@ def _vector_search(self, query: StreamQuery) -> Iterator[Observation[T]]:
ranked: list[Observation[T]] = []
for obs_id, sim in hits:
match = obs_by_id.get(obs_id)
if match is not None:
ranked.append(
match.derive(data=match.data, embedding=query.search_vec, similarity=sim)
)
if match is None:
continue
if not all(f.matches(match) for f in query.filters):
continue
ranked.append(
match.derive(data=match.data, embedding=query.search_vec, similarity=sim)
)

# Apply remaining query ops (skip vector search)
rest = replace(query, search_vec=None, search_k=None)
# Apply remaining query ops (filters already applied; skip vector search)
rest = replace(query, search_vec=None, search_k=None, filters=())
yield from rest.apply(iter(ranked))

def _iterate_live(
Expand Down
11 changes: 9 additions & 2 deletions dimos/memory/stream.py
Original file line number Diff line number Diff line change
Expand Up @@ -568,8 +568,15 @@ def drain(self) -> int:
return n

def drain_thread(self) -> DisposableBase:
"""Drain this stream on the dimos thread pool; returns a disposable."""
return self.subscribe(lambda _: None)
"""Drain this stream on the dimos thread pool; returns a disposable.

A drain has no consumer to surface an iteration error, so it logs -
a dead pipeline must not die silently.
"""
return self.subscribe(
lambda _: None,
on_error=lambda e: logger.error("drain_thread() pipeline died: %s", e, exc_info=e),
)

def observable(self) -> reactivex.Observable[O]:
"""Convert this stream to an RxPY Observable.
Expand Down
3 changes: 3 additions & 0 deletions dimos/memory/utils/sqlite.py
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,9 @@ def open_sqlite_connection(path: str | Path) -> sqlite3.Connection:
conn = sqlite3.connect(path, check_same_thread=False)
conn.execute("PRAGMA journal_mode=WAL")
conn.execute("PRAGMA synchronous=NORMAL")
# WAL has one writer at a time; concurrent writer threads (a recorder
# feed plus a live embed save) can starve past the 5s default and die.
conn.execute("PRAGMA busy_timeout=30000")
conn.enable_load_extension(True)
sqlite_vec.load(conn)
conn.enable_load_extension(False)
Expand Down
192 changes: 165 additions & 27 deletions dimos/perception/detection/detectors/owlv2.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@

from __future__ import annotations

from collections import OrderedDict
from functools import cached_property

import numpy as np
Expand All @@ -27,9 +28,14 @@
from dimos.perception.detection.type.detection2d.bbox import Detection2DBBox
from dimos.perception.detection.type.detection2d.imageDetections2D import ImageDetections2D

# ~3.8 MB of GPU memory per cached frame
_FEATURE_CACHE_MAX = 128


class Owlv2Config(HuggingFaceModelConfig):
model_name: str = "google/owlv2-base-patch16-ensemble"
# float16 runs the forward under autocast at roughly half the latency;
# scores jitter by a few thousandths, so threshold-edge boxes may flip.
dtype: torch.dtype = torch.float32


Expand All @@ -45,6 +51,22 @@ class Owlv2Detector(HuggingFaceModel):

config: Owlv2Config

def __init__(self, **kwargs: object) -> None:
super().__init__(**kwargs)
# image-tower forwards run so far; instrumentation reads deltas
self.forwards = 0
# (frame ts, label, floor) to that label's floor-filtered
# (boxes_kx4, scores_k); filled and read by localize
self.score_cache: OrderedDict[tuple[float, str, float], tuple[np.ndarray, np.ndarray]] = (
OrderedDict()
)
# frame ts to text-independent tower outputs; any query set scores
# against a cached frame without rerunning the tower
self._features: OrderedDict[
float, tuple[torch.Tensor, torch.Tensor, torch.Tensor, torch.Tensor]
] = OrderedDict()
self._text_embeds: dict[str, torch.Tensor] = {}

@cached_property
def _model(self): # type: ignore[no-untyped-def]
from transformers import Owlv2ForObjectDetection
Expand All @@ -62,6 +84,13 @@ def _processor(self): # type: ignore[no-untyped-def]

return Owlv2Processor.from_pretrained(self.config.model_name)

def _autocast(self) -> torch.autocast:
return torch.autocast(
device_type="cuda",
dtype=self.config.dtype,
enabled=self.config.dtype is not torch.float32 and "cuda" in str(self.config.device),
)

def query_detections(
self,
image: Image,
Expand All @@ -74,38 +103,55 @@ def query_detections(
``confidence`` is the calibrated per-box score. ``class_id`` indexes
into ``queries``.
"""
pil = PILImage.fromarray(image.to_rgb().data)
with torch.inference_mode():
inputs = self._processor(text=[queries], images=pil, return_tensors="pt").to(
self.config.device
)
return self.query_detections_batch([image], queries, threshold)[0]

def query_detections_batch(
self,
images: list[Image],
queries: list[str],
threshold: float = 0.1,
) -> list[ImageDetections2D]:
"""``query_detections`` over several images in one forward pass.

Per-call preprocessing, text encoding and kernel launches amortize
across the batch, which is what makes many-frame sweeps affordable;
results are per-image, in input order.
"""
self.forwards += len(images)
pils = [PILImage.fromarray(image.to_rgb().data) for image in images]
with torch.inference_mode(), self._autocast():
inputs = self._processor(
text=[queries] * len(pils), images=pils, return_tensors="pt"
).to(self.config.device)
outputs = self._model(**inputs)
results = self._processor.post_process_grounded_object_detection(
outputs=outputs,
target_sizes=torch.tensor([(pil.height, pil.width)]),
target_sizes=torch.tensor([(pil.height, pil.width) for pil in pils]),
threshold=threshold,
)[0]

detections: list[Detection2DBBox] = []
w, h = float(pil.width), float(pil.height)
for box, score, label in zip(
results["boxes"], results["scores"], results["labels"], strict=False
):
x1, y1, x2, y2 = (float(v) for v in box)
bbox = (max(0.0, x1), max(0.0, y1), min(w, x2), min(h, y2))
det = Detection2DBBox(
bbox=bbox,
track_id=-1,
class_id=int(label),
confidence=float(score),
name=queries[int(label)],
ts=image.ts,
image=image,
)
if det.is_valid():
detections.append(det)

return ImageDetections2D(image=image, detections=detections)
batch: list[ImageDetections2D] = []
for image, pil, result in zip(images, pils, results, strict=True):
detections: list[Detection2DBBox] = []
w, h = float(pil.width), float(pil.height)
for box, score, label in zip(
result["boxes"], result["scores"], result["labels"], strict=False
):
x1, y1, x2, y2 = (float(v) for v in box)
bbox = (max(0.0, x1), max(0.0, y1), min(w, x2), min(h, y2))
det = Detection2DBBox(
bbox=bbox,
track_id=-1,
class_id=int(label),
confidence=float(score),
name=queries[int(label)],
ts=image.ts,
image=image,
)
if det.is_valid():
detections.append(det)
batch.append(ImageDetections2D(image=image, detections=detections))
return batch

def query_score_rows(
self,
Expand All @@ -122,8 +168,9 @@ def query_score_rows(
queries and refuse. Returns pixel ``(x1, y1, x2, y2)`` boxes and
their score rows.
"""
self.forwards += 1
pil = PILImage.fromarray(image.to_rgb().data)
with torch.inference_mode():
with torch.inference_mode(), self._autocast():
inputs = self._processor(text=[queries], images=pil, return_tensors="pt").to(
self.config.device
)
Expand All @@ -143,7 +190,98 @@ def query_score_rows(
boxes[:, 1::2] = boxes[:, 1::2].clip(0.0, float(pil.height))
return boxes, kept

def _frame_features(
self, image: Image
) -> tuple[torch.Tensor, torch.Tensor, torch.Tensor, torch.Tensor]:
"""Text-independent tower outputs for one frame, cached by frame ts.

Returns unit-normalized per-box class embeddings, the class head's
logit shift and scale, and clipped pixel ``(x1, y1, x2, y2)`` boxes.
A miss runs the image tower once; a hit runs nothing.
"""
key = image.ts
if key in self._features:
self._features.move_to_end(key)
return self._features[key]

from transformers.image_transforms import center_to_corners_format

self.forwards += 1
pil = PILImage.fromarray(image.to_rgb().data)
model = self._model
head = model.class_head
with torch.inference_mode(), self._autocast():
pixel_values = self._processor(images=pil, return_tensors="pt").pixel_values.to(
self.config.device
)
feature_map = model.image_embedder(pixel_values)[0]
batch, height, width, dim = feature_map.shape
image_feats = feature_map.reshape(batch, height * width, dim)
pred_boxes = model.box_predictor(image_feats, feature_map)
class_embeds = head.dense0(image_feats)
class_embeds = class_embeds / (
torch.linalg.norm(class_embeds, dim=-1, keepdim=True) + 1e-6
)
logit_shift = head.logit_shift(image_feats)
logit_scale = head.elu(head.logit_scale(image_feats)) + 1

# the same conversion post_process_grounded_object_detection runs:
# corners in model dtype, scaled to the padded square in float32
boxes = center_to_corners_format(pred_boxes)[0].float() * float(
max(pil.width, pil.height)
)
boxes[:, 0::2] = boxes[:, 0::2].clip(0.0, float(pil.width))
boxes[:, 1::2] = boxes[:, 1::2].clip(0.0, float(pil.height))

entry = (class_embeds[0], logit_shift[0], logit_scale[0], boxes)
self._features[key] = entry
if len(self._features) > _FEATURE_CACHE_MAX:
self._features.popitem(last=False)
return entry

def _query_embeds(self, queries: list[str]) -> torch.Tensor:
"""Unit-normalized text embeddings, one row per query, cached per string."""
missing = [q for q in queries if q not in self._text_embeds]
if missing:
with torch.inference_mode(), self._autocast():
inputs = self._processor(text=[missing], return_tensors="pt").to(self.config.device)
embeds = self._model.owlv2.get_text_features(**inputs)
embeds = embeds / torch.linalg.norm(embeds, ord=2, dim=-1, keepdim=True)
for query, embed in zip(missing, embeds, strict=True):
self._text_embeds[query] = embed
return torch.stack([self._text_embeds[q] for q in queries])

def query_score_rows_batch(
self,
images: list[Image],
queries: list[str],
threshold: float = 0.1,
) -> list[tuple[np.ndarray, np.ndarray]]:
"""``query_score_rows`` over several images: one ``(boxes, rows)`` each.

The image tower runs only for frames missing from the feature cache;
scoring any query set against a cached frame is a matmul against its
stored class embeddings, so repeated windows and new labels on seen
frames cost no model forwards.
"""
with torch.inference_mode():
query_embeds = self._query_embeds(queries)
query_embeds = query_embeds / (
torch.linalg.norm(query_embeds, dim=-1, keepdim=True) + 1e-6
)
out: list[tuple[np.ndarray, np.ndarray]] = []
for image in images:
class_embeds, logit_shift, logit_scale, boxes_px = self._frame_features(image)
logits = (class_embeds @ query_embeds.T + logit_shift) * logit_scale
scores = torch.sigmoid(logits.to(torch.float32))
keep = scores.max(dim=-1).values > threshold
out.append((boxes_px[keep].cpu().numpy(), scores[keep].cpu().numpy()))
return out

def stop(self) -> None:
self.score_cache.clear()
self._features.clear()
self._text_embeds.clear()
if "_processor" in self.__dict__:
del self.__dict__["_processor"]
super().stop()
Loading
Loading