mirror of
https://github.com/huggingface/lerobot.git
synced 2026-07-23 17:56:07 +00:00
navigation: port voxel map + occupancy/A* mapping backbone
Copied from the dyna360 research stack into lerobot.navigation: - voxel_map.py: sparse-hash VoxelMap (5 cm default), count-weighted running-mean geometry + optional per-voxel semantic feature; carve() for DynaMem-style free-space removal (dynamic updates), query() for top-k cosine text matches, remove_voxels_in_box() for scene mutation. Added an inverse.reshape(-1) guard for numpy 2.x's np.unique. - occupancy.py: derived 3-class top-down grid (UNOBSERVED/NAVIGABLE/ OBSTACLE) projected from the voxel map, A* with no corner-cutting, obstacle inflation, and frontier extraction for exploration. Added OccupancyGrid.is_obstacle() used by SafeBaseController's occupancy gate. Pure numpy, no torch/hardware/SDK. 42 new tests (71 total in tests/navigation/). Next: LingBot-Map geometry runner + integration. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -31,12 +31,33 @@ from .base_controller import (
|
||||
odometry_to_world_pose,
|
||||
world_velocity_to_body,
|
||||
)
|
||||
from .occupancy import (
|
||||
NAVIGABLE,
|
||||
OBSTACLE,
|
||||
UNOBSERVED,
|
||||
OccupancyGrid,
|
||||
astar,
|
||||
find_frontier_cells,
|
||||
project_voxel_map_to_grid,
|
||||
)
|
||||
from .voxel_map import CarveResult, QueryResult, VoxelMap, VoxelSnapshot
|
||||
|
||||
__all__ = [
|
||||
"NAVIGABLE",
|
||||
"OBSTACLE",
|
||||
"UNOBSERVED",
|
||||
"BaseController",
|
||||
"CarveResult",
|
||||
"OccupancyGrid",
|
||||
"QueryResult",
|
||||
"RobotBaseController",
|
||||
"SafeBaseController",
|
||||
"StubBaseController",
|
||||
"VoxelMap",
|
||||
"VoxelSnapshot",
|
||||
"astar",
|
||||
"find_frontier_cells",
|
||||
"odometry_to_world_pose",
|
||||
"project_voxel_map_to_grid",
|
||||
"world_velocity_to_body",
|
||||
]
|
||||
|
||||
@@ -0,0 +1,371 @@
|
||||
#!/usr/bin/env python
|
||||
|
||||
# Copyright 2026 The HuggingFace Inc. team. All rights reserved.
|
||||
#
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
|
||||
"""2D occupancy projection of the voxel map + A* path planning.
|
||||
|
||||
Ported from the dyna360 research stack. Derived, not maintained: every
|
||||
call to :func:`project_voxel_map_to_grid` rebuilds the 3-class grid from
|
||||
a fresh ``VoxelMap.snapshot()``, so the projection reflects whatever the
|
||||
keyframe loop most recently carved or added — no separate obstacle
|
||||
structure to keep in sync.
|
||||
|
||||
Coordinate convention: OpenCV (X right, Y *down*, Z forward), matching
|
||||
the navigation world frame. "Up" is the −Y direction. The top-down grid
|
||||
indexes the XZ plane; cell ``(iz, ix)`` covers world rectangle
|
||||
``[origin_x + ix·cell, origin_x + (ix+1)·cell]`` ×
|
||||
``[origin_z + iz·cell, origin_z + (iz+1)·cell]``.
|
||||
|
||||
Classes:
|
||||
- ``UNOBSERVED`` (0): no voxel projects here. The base must not plan
|
||||
through it (might be an unseen obstacle), but explorers treat it as
|
||||
the goal class.
|
||||
- ``NAVIGABLE`` (1): observed ground / open space.
|
||||
- ``OBSTACLE`` (2): at least one voxel in the robot-height band
|
||||
projects here.
|
||||
"""
|
||||
|
||||
# ruff: noqa: N806 — H, W, D are conventional array-dimension names (and appear verbatim in error strings)
|
||||
from __future__ import annotations
|
||||
|
||||
import heapq
|
||||
import logging
|
||||
import math
|
||||
from dataclasses import dataclass
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
import numpy as np
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from lerobot.navigation.voxel_map import VoxelMap
|
||||
|
||||
LOG = logging.getLogger(__name__)
|
||||
|
||||
# Class constants — picked so a colormap can index directly.
|
||||
UNOBSERVED = np.int8(0)
|
||||
NAVIGABLE = np.int8(1)
|
||||
OBSTACLE = np.int8(2)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class OccupancyGrid:
|
||||
"""3-class top-down grid plus its world↔cell mapping."""
|
||||
|
||||
classes: np.ndarray # (H, W) int8 — H = z-extent, W = x-extent
|
||||
cell_size: float # m per cell
|
||||
origin_x: float # world x of the LEFT edge of column 0
|
||||
origin_z: float # world z of the TOP edge of row 0
|
||||
ground_y: float # world y of the (auto-estimated or given) ground plane
|
||||
|
||||
@property
|
||||
def shape(self) -> tuple[int, int]:
|
||||
return self.classes.shape # (H, W)
|
||||
|
||||
def world_to_cell(self, x: float, z: float) -> tuple[int, int]:
|
||||
"""Return ``(iz, ix)``, clipped to grid extents."""
|
||||
ix = int(np.clip(math.floor((x - self.origin_x) / self.cell_size), 0, self.shape[1] - 1))
|
||||
iz = int(np.clip(math.floor((z - self.origin_z) / self.cell_size), 0, self.shape[0] - 1))
|
||||
return iz, ix
|
||||
|
||||
def cell_to_world(self, iz: int, ix: int) -> tuple[float, float]:
|
||||
"""Return ``(x, z)`` at the *centre* of cell ``(iz, ix)``."""
|
||||
x = self.origin_x + (ix + 0.5) * self.cell_size
|
||||
z = self.origin_z + (iz + 0.5) * self.cell_size
|
||||
return x, z
|
||||
|
||||
def is_navigable(self, iz: int, ix: int) -> bool:
|
||||
H, W = self.shape
|
||||
return 0 <= iz < H and 0 <= ix < W and self.classes[iz, ix] == NAVIGABLE
|
||||
|
||||
def is_obstacle(self, iz: int, ix: int) -> bool:
|
||||
"""Whether cell ``(iz, ix)`` is a known obstacle. Out-of-bounds is
|
||||
not an obstacle (it is simply unobservable) — used by
|
||||
``SafeBaseController``'s occupancy gate."""
|
||||
H, W = self.shape
|
||||
return 0 <= iz < H and 0 <= ix < W and self.classes[iz, ix] == OBSTACLE
|
||||
|
||||
def is_in_bounds(self, iz: int, ix: int) -> bool:
|
||||
H, W = self.shape
|
||||
return 0 <= iz < H and 0 <= ix < W
|
||||
|
||||
def nearest_navigable_cell(self, iz: int, ix: int, max_radius: int = 50) -> tuple[int, int] | None:
|
||||
"""BFS outward until a navigable cell is found, or give up."""
|
||||
if self.is_navigable(iz, ix):
|
||||
return iz, ix
|
||||
for r in range(1, max_radius + 1):
|
||||
for diz in range(-r, r + 1):
|
||||
for dix in range(-r, r + 1):
|
||||
if max(abs(diz), abs(dix)) != r:
|
||||
continue # ring only, not the interior
|
||||
if self.is_navigable(iz + diz, ix + dix):
|
||||
return iz + diz, ix + dix
|
||||
return None
|
||||
|
||||
|
||||
def estimate_ground_y(xyz: np.ndarray, percentile: float = 95.0) -> float:
|
||||
"""Estimate the world-frame y of the ground plane.
|
||||
|
||||
Y is down (OpenCV), so the ground is at the LARGEST y values. Using a
|
||||
high percentile (default 95) is robust to outliers below the ground.
|
||||
"""
|
||||
if xyz.size == 0:
|
||||
return 0.0
|
||||
return float(np.percentile(xyz[:, 1], percentile))
|
||||
|
||||
|
||||
def project_voxel_map_to_grid(
|
||||
voxel_map: VoxelMap,
|
||||
*,
|
||||
cell_size: float = 0.1,
|
||||
ground_y: float | None = None,
|
||||
obstacle_y_range: tuple[float, float] = (-2.0, -0.1),
|
||||
bbox: tuple[float, float, float, float] | None = None,
|
||||
bbox_pad: float = 1.0,
|
||||
inflate_cells: int = 0,
|
||||
) -> OccupancyGrid:
|
||||
"""Snapshot the voxel map and project it into a 2D occupancy grid.
|
||||
|
||||
``obstacle_y_range`` is interpreted *relative* to ``ground_y`` with the
|
||||
Y-down convention, so the default ``(-2.0, -0.1)`` means "voxels
|
||||
between 2.0 m and 0.1 m above the ground are obstacles". Anything above
|
||||
the ceiling band or below ground level is silently ignored.
|
||||
|
||||
``inflate_cells`` dilates the OBSTACLE class by N cells of clearance
|
||||
(square morphology) — a body-radius safety margin for the base without
|
||||
resampling the voxel map.
|
||||
"""
|
||||
snap = voxel_map.snapshot()
|
||||
xyz = snap.xyz
|
||||
|
||||
if xyz.size == 0:
|
||||
# Empty map → a 1×1 grid of UNOBSERVED at world origin.
|
||||
return OccupancyGrid(
|
||||
classes=np.zeros((1, 1), dtype=np.int8),
|
||||
cell_size=float(cell_size),
|
||||
origin_x=0.0,
|
||||
origin_z=0.0,
|
||||
ground_y=ground_y if ground_y is not None else 0.0,
|
||||
)
|
||||
|
||||
if ground_y is None:
|
||||
ground_y = estimate_ground_y(xyz)
|
||||
|
||||
# Promote to float64 — VoxelMap snapshots are float32, and naive
|
||||
# ``(float32_array <= float64_scalar)`` lets numpy downcast the scalar
|
||||
# back to float32, which causes edge-case bugs (e.g. 1.0 <= 0.999999999
|
||||
# becomes True because the threshold rounds up to 1.0 in float32).
|
||||
x_arr = xyz[:, 0].astype(np.float64)
|
||||
y_arr = xyz[:, 1].astype(np.float64)
|
||||
z_arr = xyz[:, 2].astype(np.float64)
|
||||
|
||||
abs_y_top = ground_y + obstacle_y_range[0] # most-negative y (highest above ground)
|
||||
abs_y_bottom = ground_y + obstacle_y_range[1] # closer to ground
|
||||
is_obstacle = (y_arr >= abs_y_top) & (y_arr <= abs_y_bottom)
|
||||
|
||||
if bbox is None:
|
||||
x_min = float(x_arr.min()) - bbox_pad
|
||||
x_max = float(x_arr.max()) + bbox_pad
|
||||
z_min = float(z_arr.min()) - bbox_pad
|
||||
z_max = float(z_arr.max()) + bbox_pad
|
||||
else:
|
||||
x_min, z_min, x_max, z_max = bbox
|
||||
|
||||
W = max(1, int(math.ceil((x_max - x_min) / cell_size)))
|
||||
H = max(1, int(math.ceil((z_max - z_min) / cell_size)))
|
||||
classes = np.zeros((H, W), dtype=np.int8) # default UNOBSERVED
|
||||
|
||||
# eps absorbs float32→float64 representation drift so points that should
|
||||
# land exactly on a cell boundary aren't randomly bumped into the
|
||||
# previous cell. 1e-3 of a cell width is well above float32's ~1e-7
|
||||
# relative precision and well below the 0.5-cell misclassification
|
||||
# threshold.
|
||||
eps = cell_size * 1e-3
|
||||
ix = np.clip(np.floor((x_arr - x_min) / cell_size + eps).astype(np.int32), 0, W - 1)
|
||||
iz = np.clip(np.floor((z_arr - z_min) / cell_size + eps).astype(np.int32), 0, H - 1)
|
||||
|
||||
# Two-pass labelling: any voxel makes a cell observed (-> NAVIGABLE);
|
||||
# obstacle voxels then upgrade those cells to OBSTACLE.
|
||||
classes[iz, ix] = NAVIGABLE
|
||||
obs_iz = iz[is_obstacle]
|
||||
obs_ix = ix[is_obstacle]
|
||||
classes[obs_iz, obs_ix] = OBSTACLE
|
||||
|
||||
if inflate_cells > 0:
|
||||
classes = _inflate_obstacles(classes, inflate_cells)
|
||||
|
||||
return OccupancyGrid(
|
||||
classes=classes,
|
||||
cell_size=float(cell_size),
|
||||
origin_x=x_min,
|
||||
origin_z=z_min,
|
||||
ground_y=float(ground_y),
|
||||
)
|
||||
|
||||
|
||||
def _inflate_obstacles(classes: np.ndarray, radius: int) -> np.ndarray:
|
||||
"""Dilate OBSTACLE cells by `radius` cells (Chebyshev). Pure-numpy
|
||||
morphological dilation — fine for our grid sizes."""
|
||||
out = classes.copy()
|
||||
obs = classes == OBSTACLE
|
||||
H, W = classes.shape
|
||||
for diz in range(-radius, radius + 1):
|
||||
for dix in range(-radius, radius + 1):
|
||||
if diz == 0 and dix == 0:
|
||||
continue
|
||||
sl_src_iz = slice(max(0, -diz), H - max(0, diz))
|
||||
sl_src_ix = slice(max(0, -dix), W - max(0, dix))
|
||||
sl_dst_iz = slice(max(0, diz), H - max(0, -diz))
|
||||
sl_dst_ix = slice(max(0, dix), W - max(0, -dix))
|
||||
inflated = obs[sl_src_iz, sl_src_ix]
|
||||
# Only upgrade NAVIGABLE → OBSTACLE; never overwrite UNOBSERVED
|
||||
# so the frontier (NAVIGABLE↔UNOBSERVED boundary) survives.
|
||||
target = out[sl_dst_iz, sl_dst_ix]
|
||||
promote = inflated & (target == NAVIGABLE)
|
||||
target[promote] = OBSTACLE
|
||||
out[sl_dst_iz, sl_dst_ix] = target
|
||||
return out
|
||||
|
||||
|
||||
# --------------------------------------------------------------------- A*
|
||||
|
||||
_DIAG_COST = math.sqrt(2.0)
|
||||
_NEIGHBOURS_ORTHO = ((-1, 0), (1, 0), (0, -1), (0, 1))
|
||||
_NEIGHBOURS_DIAG = ((-1, -1), (-1, 1), (1, -1), (1, 1))
|
||||
|
||||
|
||||
def astar(
|
||||
grid: OccupancyGrid,
|
||||
start_world: tuple[float, float],
|
||||
goal_world: tuple[float, float],
|
||||
*,
|
||||
allow_unobserved_goal: bool = True,
|
||||
) -> list[tuple[float, float]] | None:
|
||||
"""Plan a path from ``start_world`` to ``goal_world`` in (x, z) world m.
|
||||
|
||||
Returns a list of world ``(x, z)`` waypoints, or ``None`` if no path
|
||||
exists. Start/goal are snapped to the nearest navigable cell.
|
||||
"""
|
||||
H, W = grid.shape
|
||||
if H == 0 or W == 0:
|
||||
return None
|
||||
|
||||
s_iz, s_ix = grid.world_to_cell(*start_world)
|
||||
g_iz, g_ix = grid.world_to_cell(*goal_world)
|
||||
|
||||
if not grid.is_navigable(s_iz, s_ix):
|
||||
snapped = grid.nearest_navigable_cell(s_iz, s_ix)
|
||||
if snapped is None:
|
||||
return None
|
||||
s_iz, s_ix = snapped
|
||||
if not grid.is_navigable(g_iz, g_ix):
|
||||
if not allow_unobserved_goal:
|
||||
return None
|
||||
snapped = grid.nearest_navigable_cell(g_iz, g_ix)
|
||||
if snapped is None:
|
||||
return None
|
||||
g_iz, g_ix = snapped
|
||||
|
||||
def heuristic(iz: int, ix: int) -> float:
|
||||
d_iz = abs(iz - g_iz)
|
||||
d_ix = abs(ix - g_ix)
|
||||
return (max(d_iz, d_ix) - min(d_iz, d_ix)) + _DIAG_COST * min(d_iz, d_ix)
|
||||
|
||||
open_heap: list[tuple[float, int, tuple[int, int]]] = []
|
||||
counter = 0 # tiebreaker so heapq doesn't compare tuples on ties
|
||||
heapq.heappush(open_heap, (0.0, counter, (s_iz, s_ix)))
|
||||
came_from: dict[tuple[int, int], tuple[int, int]] = {}
|
||||
g_score: dict[tuple[int, int], float] = {(s_iz, s_ix): 0.0}
|
||||
|
||||
while open_heap:
|
||||
_, _, current = heapq.heappop(open_heap)
|
||||
if current == (g_iz, g_ix):
|
||||
return _reconstruct_path(came_from, current, grid)
|
||||
|
||||
cur_iz, cur_ix = current
|
||||
cur_g = g_score[current]
|
||||
|
||||
for diz, dix in _NEIGHBOURS_ORTHO:
|
||||
n = (cur_iz + diz, cur_ix + dix)
|
||||
if not grid.is_navigable(*n):
|
||||
continue
|
||||
tentative = cur_g + 1.0
|
||||
if tentative < g_score.get(n, float("inf")):
|
||||
came_from[n] = current
|
||||
g_score[n] = tentative
|
||||
counter += 1
|
||||
heapq.heappush(open_heap, (tentative + heuristic(*n), counter, n))
|
||||
|
||||
for diz, dix in _NEIGHBOURS_DIAG:
|
||||
n = (cur_iz + diz, cur_ix + dix)
|
||||
if not grid.is_navigable(*n):
|
||||
continue
|
||||
# Prevent corner-cutting: both perpendicular neighbours must be
|
||||
# navigable, or we'd squeeze through an obstacle's diagonal.
|
||||
if not grid.is_navigable(cur_iz + diz, cur_ix):
|
||||
continue
|
||||
if not grid.is_navigable(cur_iz, cur_ix + dix):
|
||||
continue
|
||||
tentative = cur_g + _DIAG_COST
|
||||
if tentative < g_score.get(n, float("inf")):
|
||||
came_from[n] = current
|
||||
g_score[n] = tentative
|
||||
counter += 1
|
||||
heapq.heappush(open_heap, (tentative + heuristic(*n), counter, n))
|
||||
|
||||
return None
|
||||
|
||||
|
||||
def _reconstruct_path(
|
||||
came_from: dict[tuple[int, int], tuple[int, int]],
|
||||
end: tuple[int, int],
|
||||
grid: OccupancyGrid,
|
||||
) -> list[tuple[float, float]]:
|
||||
cells = [end]
|
||||
while cells[-1] in came_from:
|
||||
cells.append(came_from[cells[-1]])
|
||||
cells.reverse()
|
||||
return [grid.cell_to_world(iz, ix) for iz, ix in cells]
|
||||
|
||||
|
||||
# ----------------------------------------------------------------- frontier
|
||||
|
||||
|
||||
def find_frontier_cells(grid: OccupancyGrid) -> np.ndarray:
|
||||
"""Return cells on the NAVIGABLE↔UNOBSERVED boundary, as ``(K, 2)`` int.
|
||||
|
||||
These are the cells exploration aims for: places we already know we
|
||||
can stand at, but with unknown adjacent territory worth visiting.
|
||||
"""
|
||||
nav = grid.classes == NAVIGABLE
|
||||
unobs = grid.classes == UNOBSERVED
|
||||
if not nav.any() or not unobs.any():
|
||||
return np.zeros((0, 2), dtype=np.int32)
|
||||
|
||||
boundary = np.zeros_like(nav)
|
||||
boundary[1:, :] |= nav[1:, :] & unobs[:-1, :]
|
||||
boundary[:-1, :] |= nav[:-1, :] & unobs[1:, :]
|
||||
boundary[:, 1:] |= nav[:, 1:] & unobs[:, :-1]
|
||||
boundary[:, :-1] |= nav[:, :-1] & unobs[:, 1:]
|
||||
iz, ix = np.where(boundary)
|
||||
return np.stack([iz, ix], axis=-1).astype(np.int32)
|
||||
|
||||
|
||||
def occupancy_to_rgb(grid: OccupancyGrid) -> np.ndarray:
|
||||
"""Render the 3-class grid as an (H, W, 3) uint8 image."""
|
||||
img = np.zeros((*grid.shape, 3), dtype=np.uint8)
|
||||
img[grid.classes == UNOBSERVED] = (40, 40, 50)
|
||||
img[grid.classes == NAVIGABLE] = (200, 200, 200)
|
||||
img[grid.classes == OBSTACLE] = (220, 60, 60)
|
||||
return img
|
||||
@@ -0,0 +1,508 @@
|
||||
#!/usr/bin/env python
|
||||
|
||||
# Copyright 2026 The HuggingFace Inc. team. All rights reserved.
|
||||
#
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
|
||||
"""Sparse-hash voxel memory with free-space carving + semantic features.
|
||||
|
||||
Ported from the dyna360 research stack. Per occupied voxel: voxel index,
|
||||
running-mean xyz (count-weighted), running-mean rgb (count-weighted),
|
||||
count, last_frame, last_time, and — once vision-language features have
|
||||
been fed in — a conf-weighted running-mean feature in fp16 plus the
|
||||
weight sum.
|
||||
|
||||
Storage is hybrid: a Python dict maps voxel index ``(ix, iy, iz)`` to a
|
||||
row in column-stored numpy arrays so lookup is O(1) and bulk arithmetic
|
||||
stays vectorized. ``carve`` removes voxels that fall inside a view's
|
||||
observed free space (DynaMem-style dynamic updates); ``query`` returns
|
||||
the top-k cosine matches against a text embedding.
|
||||
|
||||
Default voxel size is 5 cm. The map is geometry-only until
|
||||
``add(..., feat_map=...)`` supplies per-pixel features; occupancy /
|
||||
planning use only the geometry, so they work without any features.
|
||||
"""
|
||||
|
||||
# ruff: noqa: N806 — H, W, D are conventional array-dimension names (and appear verbatim in error strings)
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from dataclasses import dataclass
|
||||
|
||||
import numpy as np
|
||||
|
||||
LOG = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@dataclass
|
||||
class VoxelMapStats:
|
||||
"""Per-keyframe deltas, surfaced to scalar logs."""
|
||||
|
||||
n_voxels: int
|
||||
n_added: int
|
||||
n_updated: int
|
||||
n_removed: int = 0
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class VoxelSnapshot:
|
||||
"""Current voxel map state, materialized for visualization / export."""
|
||||
|
||||
xyz: np.ndarray # (M, 3) float32 — count-weighted mean position
|
||||
rgb: np.ndarray # (M, 3) uint8 — count-weighted mean color (RGB)
|
||||
count: np.ndarray # (M,) int64
|
||||
last_frame: np.ndarray # (M,) int64
|
||||
last_time: np.ndarray # (M,) float64
|
||||
feat: np.ndarray | None = None # (M, D) fp16 — L2-normalized per-voxel mean
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class CarveResult:
|
||||
"""Output of one ``carve`` pass."""
|
||||
|
||||
n_removed: int
|
||||
removed_xyz: np.ndarray # (K, 3) float32 — centres of removed voxels, for viz
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class QueryResult:
|
||||
"""Top-k cosine matches against a text embedding."""
|
||||
|
||||
xyz: np.ndarray # (k, 3) float32
|
||||
score: np.ndarray # (k,) float32 — cosine similarity in [-1, 1]
|
||||
voxel_indices: np.ndarray # (k,) int64 — row indices into the map
|
||||
|
||||
|
||||
_MAX_ABS_VOXEL_INDEX = 1 << 20
|
||||
_FEAT_CHUNK_PIXELS = 16384 # bound peak per-keyframe feature contribution memory
|
||||
|
||||
|
||||
class VoxelMap:
|
||||
"""Sparse-hash voxel grid with count-weighted means and semantic features."""
|
||||
|
||||
def __init__(self, voxel_size: float = 0.05) -> None:
|
||||
if voxel_size <= 0:
|
||||
raise ValueError("voxel_size must be > 0")
|
||||
self.voxel_size = float(voxel_size)
|
||||
|
||||
self._lookup: dict[tuple[int, int, int], int] = {}
|
||||
self._idx = np.zeros((0, 3), dtype=np.int64)
|
||||
self._count = np.zeros(0, dtype=np.int64)
|
||||
self._xyz_sum = np.zeros((0, 3), dtype=np.float64)
|
||||
self._rgb_sum = np.zeros((0, 3), dtype=np.float64)
|
||||
self._last_frame = np.zeros(0, dtype=np.int64)
|
||||
self._last_time = np.zeros(0, dtype=np.float64)
|
||||
|
||||
# Lazily allocated on first add() with feat_map.
|
||||
self._feature_dim: int | None = None
|
||||
self._feat_sum: np.ndarray | None = None # (M, D) fp16
|
||||
self._feat_weight: np.ndarray | None = None # (M,) fp32
|
||||
|
||||
def __len__(self) -> int:
|
||||
return int(self._count.shape[0])
|
||||
|
||||
@property
|
||||
def feature_dim(self) -> int | None:
|
||||
return self._feature_dim
|
||||
|
||||
# ------------------------------------------------------------------ add
|
||||
|
||||
def add(
|
||||
self,
|
||||
points: np.ndarray,
|
||||
rgb: np.ndarray,
|
||||
conf: np.ndarray,
|
||||
frame: int,
|
||||
t: float,
|
||||
conf_thresh: float = 0.5,
|
||||
feat_map: np.ndarray | None = None,
|
||||
) -> VoxelMapStats:
|
||||
"""Insert / update voxels from a per-pixel observation.
|
||||
|
||||
``points``: ``(..., 3)`` world xyz, fp32.
|
||||
``rgb``: ``(..., 3)`` uint8 (RGB order).
|
||||
``conf``: ``(...,)`` in [0, 1].
|
||||
``feat_map``: optional ``(..., D)`` fp16 per-pixel feature, already
|
||||
bilinearly upsampled to the points/conf grid. First
|
||||
call with features locks the feature dimension;
|
||||
subsequent calls must match.
|
||||
"""
|
||||
pts = np.asarray(points).reshape(-1, 3)
|
||||
cols = np.asarray(rgb).reshape(-1, 3)
|
||||
cnf = np.asarray(conf).reshape(-1)
|
||||
if not (len(pts) == len(cols) == len(cnf)):
|
||||
raise ValueError(f"length mismatch: points={len(pts)}, rgb={len(cols)}, conf={len(cnf)}")
|
||||
|
||||
features: np.ndarray | None = None
|
||||
if feat_map is not None:
|
||||
features = np.asarray(feat_map).reshape(-1, feat_map.shape[-1])
|
||||
if len(features) != len(pts):
|
||||
raise ValueError(f"feat_map length {len(features)} != points length {len(pts)}")
|
||||
D = features.shape[-1]
|
||||
if self._feature_dim is None:
|
||||
self._feature_dim = int(D)
|
||||
# Pad pre-existing voxels (added before features arrived) with zeros.
|
||||
self._feat_sum = np.zeros((len(self), D), dtype=np.float16)
|
||||
self._feat_weight = np.zeros(len(self), dtype=np.float32)
|
||||
LOG.info("VoxelMap features enabled: D=%d (fp16 storage)", D)
|
||||
elif self._feature_dim != D:
|
||||
raise ValueError(f"feature dim mismatch: existing={self._feature_dim}, got={D}")
|
||||
|
||||
mask = (cnf >= conf_thresh) & np.isfinite(pts).all(axis=1)
|
||||
pts = pts[mask]
|
||||
cols = cols[mask]
|
||||
cnf_kept = cnf[mask]
|
||||
if features is not None:
|
||||
features = features[mask]
|
||||
if pts.size == 0:
|
||||
return VoxelMapStats(n_voxels=len(self), n_added=0, n_updated=0)
|
||||
|
||||
idx = np.floor(pts / self.voxel_size).astype(np.int64)
|
||||
sane = (np.abs(idx) < _MAX_ABS_VOXEL_INDEX).all(axis=1)
|
||||
if not sane.all():
|
||||
n_drop = int((~sane).sum())
|
||||
LOG.debug("dropping %d points with extreme voxel index", n_drop)
|
||||
idx = idx[sane]
|
||||
pts = pts[sane]
|
||||
cols = cols[sane]
|
||||
cnf_kept = cnf_kept[sane]
|
||||
if features is not None:
|
||||
features = features[sane]
|
||||
if idx.size == 0:
|
||||
return VoxelMapStats(n_voxels=len(self), n_added=0, n_updated=0)
|
||||
|
||||
unique_idx, inverse = np.unique(idx, axis=0, return_inverse=True)
|
||||
inverse = inverse.reshape(-1)
|
||||
n_unique = unique_idx.shape[0]
|
||||
kf_count = np.bincount(inverse, minlength=n_unique).astype(np.int64)
|
||||
kf_xyz_sum = np.zeros((n_unique, 3), dtype=np.float64)
|
||||
kf_rgb_sum = np.zeros((n_unique, 3), dtype=np.float64)
|
||||
np.add.at(kf_xyz_sum, inverse, pts.astype(np.float64))
|
||||
np.add.at(kf_rgb_sum, inverse, cols.astype(np.float64))
|
||||
|
||||
kf_feat_sum: np.ndarray | None = None
|
||||
kf_feat_weight: np.ndarray | None = None
|
||||
if features is not None:
|
||||
kf_feat_sum = np.zeros((n_unique, self._feature_dim), dtype=np.float32)
|
||||
kf_feat_weight = np.zeros(n_unique, dtype=np.float32)
|
||||
cnf_f = cnf_kept.astype(np.float32)
|
||||
# Chunked accumulation — keeps the (chunk, D) intermediate small.
|
||||
for s in range(0, features.shape[0], _FEAT_CHUNK_PIXELS):
|
||||
e = s + _FEAT_CHUNK_PIXELS
|
||||
w = cnf_f[s:e]
|
||||
contrib = w[:, None] * features[s:e].astype(np.float32)
|
||||
np.add.at(kf_feat_sum, inverse[s:e], contrib)
|
||||
np.add.at(kf_feat_weight, inverse[s:e], w)
|
||||
|
||||
existing_rows: list[int] = []
|
||||
existing_local: list[int] = []
|
||||
new_local: list[int] = []
|
||||
new_keys: list[tuple[int, int, int]] = []
|
||||
for i in range(n_unique):
|
||||
key = (int(unique_idx[i, 0]), int(unique_idx[i, 1]), int(unique_idx[i, 2]))
|
||||
row = self._lookup.get(key)
|
||||
if row is None:
|
||||
new_local.append(i)
|
||||
new_keys.append(key)
|
||||
else:
|
||||
existing_rows.append(row)
|
||||
existing_local.append(i)
|
||||
|
||||
if existing_rows:
|
||||
rows = np.asarray(existing_rows, dtype=np.int64)
|
||||
local = np.asarray(existing_local, dtype=np.int64)
|
||||
self._count[rows] += kf_count[local]
|
||||
self._xyz_sum[rows] += kf_xyz_sum[local]
|
||||
self._rgb_sum[rows] += kf_rgb_sum[local]
|
||||
self._last_frame[rows] = frame
|
||||
self._last_time[rows] = t
|
||||
if kf_feat_sum is not None:
|
||||
assert self._feat_sum is not None and self._feat_weight is not None
|
||||
# fp32 accumulator -> fp16 storage; cast on store to match storage dtype.
|
||||
self._feat_sum[rows] = (self._feat_sum[rows].astype(np.float32) + kf_feat_sum[local]).astype(
|
||||
np.float16
|
||||
)
|
||||
self._feat_weight[rows] += kf_feat_weight[local]
|
||||
|
||||
if new_local:
|
||||
base = len(self)
|
||||
local = np.asarray(new_local, dtype=np.int64)
|
||||
self._idx = np.concatenate([self._idx, unique_idx[local]], axis=0)
|
||||
self._count = np.concatenate([self._count, kf_count[local]])
|
||||
self._xyz_sum = np.concatenate([self._xyz_sum, kf_xyz_sum[local]], axis=0)
|
||||
self._rgb_sum = np.concatenate([self._rgb_sum, kf_rgb_sum[local]], axis=0)
|
||||
self._last_frame = np.concatenate(
|
||||
[self._last_frame, np.full(len(new_local), frame, dtype=np.int64)]
|
||||
)
|
||||
self._last_time = np.concatenate([self._last_time, np.full(len(new_local), t, dtype=np.float64)])
|
||||
if self._feature_dim is not None:
|
||||
assert self._feat_sum is not None and self._feat_weight is not None
|
||||
if kf_feat_sum is not None:
|
||||
new_feats = kf_feat_sum[local].astype(np.float16)
|
||||
new_weights = kf_feat_weight[local]
|
||||
else:
|
||||
# Features enabled, but this call didn't bring any — pad zeros
|
||||
# so array sizes stay aligned with _count.
|
||||
new_feats = np.zeros((len(new_local), self._feature_dim), dtype=np.float16)
|
||||
new_weights = np.zeros(len(new_local), dtype=np.float32)
|
||||
self._feat_sum = np.concatenate([self._feat_sum, new_feats], axis=0)
|
||||
self._feat_weight = np.concatenate([self._feat_weight, new_weights])
|
||||
for offset, key in enumerate(new_keys):
|
||||
self._lookup[key] = base + offset
|
||||
|
||||
return VoxelMapStats(
|
||||
n_voxels=len(self),
|
||||
n_added=len(new_local),
|
||||
n_updated=len(existing_rows),
|
||||
)
|
||||
|
||||
# ------------------------------------------------------- hard-delete
|
||||
def remove_voxels_in_box(
|
||||
self,
|
||||
xyz_min: tuple[float, float, float],
|
||||
xyz_max: tuple[float, float, float],
|
||||
) -> int:
|
||||
"""Surgical hard-delete of every voxel whose mean position lies inside
|
||||
the axis-aligned bounding box.
|
||||
|
||||
Different from :meth:`carve` (DynaMem-style free-space removal from a
|
||||
camera frustum + depth). This one is for simulated scene mutation:
|
||||
"the couch moved away" is removing the box around the old couch then
|
||||
``add()``-ing one at the new position.
|
||||
"""
|
||||
if len(self) == 0:
|
||||
return 0
|
||||
cnt = self._count.astype(np.float64).reshape(-1, 1)
|
||||
means = self._xyz_sum / cnt
|
||||
in_box = (
|
||||
(means[:, 0] >= xyz_min[0])
|
||||
& (means[:, 0] <= xyz_max[0])
|
||||
& (means[:, 1] >= xyz_min[1])
|
||||
& (means[:, 1] <= xyz_max[1])
|
||||
& (means[:, 2] >= xyz_min[2])
|
||||
& (means[:, 2] <= xyz_max[2])
|
||||
)
|
||||
if not in_box.any():
|
||||
return 0
|
||||
keep = ~in_box
|
||||
n_removed = int(in_box.sum())
|
||||
for k in self._idx[in_box]:
|
||||
del self._lookup[(int(k[0]), int(k[1]), int(k[2]))]
|
||||
self._idx = self._idx[keep]
|
||||
self._count = self._count[keep]
|
||||
self._xyz_sum = self._xyz_sum[keep]
|
||||
self._rgb_sum = self._rgb_sum[keep]
|
||||
self._last_frame = self._last_frame[keep]
|
||||
self._last_time = self._last_time[keep]
|
||||
if self._feat_sum is not None and self._feat_weight is not None:
|
||||
self._feat_sum = self._feat_sum[keep]
|
||||
self._feat_weight = self._feat_weight[keep]
|
||||
# Row indices shifted — rebuild the lookup.
|
||||
self._lookup = {
|
||||
(int(self._idx[i, 0]), int(self._idx[i, 1]), int(self._idx[i, 2])): i
|
||||
for i in range(len(self._idx))
|
||||
}
|
||||
return n_removed
|
||||
|
||||
# ---------------------------------------------------------------- carve
|
||||
|
||||
def carve(
|
||||
self,
|
||||
local_points: np.ndarray,
|
||||
conf: np.ndarray,
|
||||
pose: np.ndarray,
|
||||
focal_px: float,
|
||||
frame: int,
|
||||
t: float,
|
||||
conf_thresh: float = 0.5,
|
||||
margin: float = 0.05,
|
||||
) -> CarveResult:
|
||||
"""Remove voxels inside this view's observed free space.
|
||||
|
||||
A voxel is carved when it projects into the image, sits in front of
|
||||
the camera, and lies closer than the observed depth (minus a margin)
|
||||
at that pixel — i.e. we can see through where it claims to be. Carve
|
||||
runs before ``add`` each keyframe so moved/removed objects disappear.
|
||||
"""
|
||||
if len(self) == 0:
|
||||
return CarveResult(0, np.zeros((0, 3), dtype=np.float32))
|
||||
|
||||
if local_points.ndim != 3 or local_points.shape[-1] != 3:
|
||||
raise ValueError(f"expected (H, W, 3), got {local_points.shape}")
|
||||
if conf.shape != local_points.shape[:2]:
|
||||
raise ValueError(f"conf shape {conf.shape} != local_points (H, W) {local_points.shape[:2]}")
|
||||
if pose.shape != (4, 4):
|
||||
raise ValueError(f"pose must be (4, 4); got {pose.shape}")
|
||||
|
||||
H, W = local_points.shape[:2]
|
||||
cx = (W - 1) / 2.0
|
||||
cy = (H - 1) / 2.0
|
||||
depth_map = local_points[..., 2]
|
||||
|
||||
cnt = self._count.astype(np.float64).reshape(-1, 1)
|
||||
xyz_world = self._xyz_sum / cnt
|
||||
|
||||
R = pose[:3, :3].astype(np.float64)
|
||||
t_vec = pose[:3, 3].astype(np.float64)
|
||||
xyz_cam = (xyz_world - t_vec[None, :]) @ R
|
||||
|
||||
d_voxel = xyz_cam[:, 2]
|
||||
front = d_voxel > 1e-3
|
||||
|
||||
d_safe = np.where(front, d_voxel, 1.0)
|
||||
u = focal_px * xyz_cam[:, 0] / d_safe + cx
|
||||
v = focal_px * xyz_cam[:, 1] / d_safe + cy
|
||||
in_bounds = (u >= 0.0) & (u < W) & (v >= 0.0) & (v < H)
|
||||
valid = front & in_bounds
|
||||
|
||||
u_i = np.clip(np.floor(u).astype(np.int64), 0, W - 1)
|
||||
v_i = np.clip(np.floor(v).astype(np.int64), 0, H - 1)
|
||||
D_at = depth_map[v_i, u_i]
|
||||
C_at = conf[v_i, u_i]
|
||||
|
||||
finite_D = np.isfinite(D_at) & (D_at > 0.0)
|
||||
free_space = valid & finite_D & (C_at >= conf_thresh) & (d_voxel < (D_at - margin))
|
||||
|
||||
n_removed = int(free_space.sum())
|
||||
if n_removed == 0:
|
||||
return CarveResult(0, np.zeros((0, 3), dtype=np.float32))
|
||||
|
||||
removed_xyz = xyz_world[free_space].astype(np.float32)
|
||||
removed_keys = self._idx[free_space]
|
||||
for k in removed_keys:
|
||||
del self._lookup[(int(k[0]), int(k[1]), int(k[2]))]
|
||||
|
||||
keep = ~free_space
|
||||
self._idx = self._idx[keep]
|
||||
self._count = self._count[keep]
|
||||
self._xyz_sum = self._xyz_sum[keep]
|
||||
self._rgb_sum = self._rgb_sum[keep]
|
||||
self._last_frame = self._last_frame[keep]
|
||||
self._last_time = self._last_time[keep]
|
||||
if self._feat_sum is not None and self._feat_weight is not None:
|
||||
self._feat_sum = self._feat_sum[keep]
|
||||
self._feat_weight = self._feat_weight[keep]
|
||||
|
||||
self._lookup = {
|
||||
(int(self._idx[i, 0]), int(self._idx[i, 1]), int(self._idx[i, 2])): i
|
||||
for i in range(len(self._idx))
|
||||
}
|
||||
LOG.debug("carve frame=%d t=%.3fs removed=%d", frame, t, n_removed)
|
||||
return CarveResult(n_removed=n_removed, removed_xyz=removed_xyz)
|
||||
|
||||
# ------------------------------------------------------------- snapshot
|
||||
|
||||
def snapshot(self, include_features: bool = False) -> VoxelSnapshot:
|
||||
"""Materialize the current map.
|
||||
|
||||
``include_features``: pay the cost of normalizing the per-voxel
|
||||
feature mean. Off by default — visualization doesn't need features.
|
||||
"""
|
||||
if len(self) == 0:
|
||||
return VoxelSnapshot(
|
||||
xyz=np.zeros((0, 3), dtype=np.float32),
|
||||
rgb=np.zeros((0, 3), dtype=np.uint8),
|
||||
count=np.zeros(0, dtype=np.int64),
|
||||
last_frame=np.zeros(0, dtype=np.int64),
|
||||
last_time=np.zeros(0, dtype=np.float64),
|
||||
feat=None,
|
||||
)
|
||||
cnt = self._count.astype(np.float64).reshape(-1, 1)
|
||||
xyz = (self._xyz_sum / cnt).astype(np.float32)
|
||||
rgb = np.clip(self._rgb_sum / cnt, 0, 255).astype(np.uint8)
|
||||
|
||||
feat = None
|
||||
if include_features and self._feat_sum is not None and self._feat_weight is not None:
|
||||
feat = self._normalized_features()
|
||||
|
||||
return VoxelSnapshot(
|
||||
xyz=xyz,
|
||||
rgb=rgb,
|
||||
count=self._count.copy(),
|
||||
last_frame=self._last_frame.copy(),
|
||||
last_time=self._last_time.copy(),
|
||||
feat=feat,
|
||||
)
|
||||
|
||||
def _normalized_features(self) -> np.ndarray:
|
||||
"""Per-voxel L2-normalized feature mean. (M, D) fp16."""
|
||||
assert self._feat_sum is not None and self._feat_weight is not None
|
||||
w = np.maximum(self._feat_weight, 1e-6).reshape(-1, 1)
|
||||
mean = self._feat_sum.astype(np.float32) / w
|
||||
norms = np.linalg.norm(mean, axis=1, keepdims=True)
|
||||
mean = mean / np.maximum(norms, 1e-6)
|
||||
return mean.astype(np.float16)
|
||||
|
||||
# ----------------------------------------------------------------- query
|
||||
|
||||
def query(self, text_embedding: np.ndarray, top_k: int = 32) -> QueryResult:
|
||||
"""Top-k cosine matches against ``text_embedding``.
|
||||
|
||||
``text_embedding``: ``(D,)`` array — does NOT need to be unit norm;
|
||||
we re-normalize.
|
||||
"""
|
||||
if self._feat_sum is None or self._feature_dim is None:
|
||||
raise RuntimeError("VoxelMap has no semantic features yet — call add(..., feat_map=...) first")
|
||||
if len(self) == 0:
|
||||
return QueryResult(
|
||||
xyz=np.zeros((0, 3), dtype=np.float32),
|
||||
score=np.zeros(0, dtype=np.float32),
|
||||
voxel_indices=np.zeros(0, dtype=np.int64),
|
||||
)
|
||||
if text_embedding.shape != (self._feature_dim,):
|
||||
raise ValueError(f"text_embedding shape {text_embedding.shape} != ({self._feature_dim},)")
|
||||
|
||||
voxel_feat = self._normalized_features().astype(np.float32)
|
||||
text_unit = text_embedding.astype(np.float32)
|
||||
text_unit = text_unit / max(float(np.linalg.norm(text_unit)), 1e-6)
|
||||
|
||||
scores = voxel_feat @ text_unit # (M,)
|
||||
k = min(int(top_k), len(scores))
|
||||
# Partition-and-sort for the top-k.
|
||||
top_idx = np.argpartition(scores, -k)[-k:]
|
||||
order = np.argsort(-scores[top_idx])
|
||||
top_idx = top_idx[order]
|
||||
|
||||
snap_xyz = (self._xyz_sum[top_idx] / self._count[top_idx].astype(np.float64).reshape(-1, 1)).astype(
|
||||
np.float32
|
||||
)
|
||||
return QueryResult(
|
||||
xyz=snap_xyz,
|
||||
score=scores[top_idx].astype(np.float32),
|
||||
voxel_indices=top_idx.astype(np.int64),
|
||||
)
|
||||
|
||||
# --------------------------------------------------------- introspection
|
||||
|
||||
def memory_bytes(self) -> dict[str, int]:
|
||||
"""Return per-array memory footprint."""
|
||||
out = {
|
||||
"idx": self._idx.nbytes,
|
||||
"count": self._count.nbytes,
|
||||
"xyz_sum": self._xyz_sum.nbytes,
|
||||
"rgb_sum": self._rgb_sum.nbytes,
|
||||
"last_frame": self._last_frame.nbytes,
|
||||
"last_time": self._last_time.nbytes,
|
||||
"lookup_dict": _approx_dict_bytes(self._lookup),
|
||||
}
|
||||
if self._feat_sum is not None:
|
||||
out["feat_sum"] = self._feat_sum.nbytes
|
||||
assert self._feat_weight is not None
|
||||
out["feat_weight"] = self._feat_weight.nbytes
|
||||
out["total"] = sum(v for k, v in out.items() if k != "total")
|
||||
return out
|
||||
|
||||
|
||||
def _approx_dict_bytes(d: dict) -> int:
|
||||
"""Rough lower-bound estimate; ~100 bytes/entry is a fine ballpark."""
|
||||
return 100 * len(d)
|
||||
@@ -0,0 +1,266 @@
|
||||
#!/usr/bin/env python
|
||||
|
||||
# Copyright 2026 The HuggingFace Inc. team. All rights reserved.
|
||||
#
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
|
||||
"""Unit tests for the B1 occupancy projection + A*."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import numpy as np
|
||||
|
||||
from lerobot.navigation.occupancy import (
|
||||
NAVIGABLE,
|
||||
OBSTACLE,
|
||||
UNOBSERVED,
|
||||
OccupancyGrid,
|
||||
astar,
|
||||
estimate_ground_y,
|
||||
find_frontier_cells,
|
||||
occupancy_to_rgb,
|
||||
project_voxel_map_to_grid,
|
||||
)
|
||||
from lerobot.navigation.voxel_map import VoxelMap
|
||||
|
||||
|
||||
def _vm_from_points(pts: list[tuple[float, float, float]], voxel_size: float = 0.1) -> VoxelMap:
|
||||
"""Build a VoxelMap from a list of world-XYZ points (all at conf=1.0)."""
|
||||
vm = VoxelMap(voxel_size=voxel_size)
|
||||
if not pts:
|
||||
return vm
|
||||
arr = np.asarray(pts, dtype=np.float32).reshape(-1, 1, 3)
|
||||
rgb = np.full((len(pts), 1, 3), 200, dtype=np.uint8)
|
||||
conf = np.ones((len(pts), 1), dtype=np.float32)
|
||||
vm.add(arr, rgb, conf, frame=0, t=0.0)
|
||||
return vm
|
||||
|
||||
|
||||
def test_estimate_ground_y_picks_high_percentile():
|
||||
# +y is down in OpenCV; ground = largest y. Numpy's default percentile
|
||||
# interpolation lands at 0.9 here (95% of way from 0.5 to 1.0 of the
|
||||
# last interval = 0.5 + 0.4·1.0 = 0.9), so we just check the estimate
|
||||
# is at least that high and clearly above the median.
|
||||
xyz = np.array(
|
||||
[[0, -1, 0], [0, -0.5, 0], [0, 0.0, 0], [0, 0.5, 0], [0, 1.0, 0]],
|
||||
dtype=np.float32,
|
||||
)
|
||||
g = estimate_ground_y(xyz)
|
||||
assert g >= 0.8
|
||||
median_y = float(np.median(xyz[:, 1]))
|
||||
assert g > median_y
|
||||
|
||||
|
||||
def test_projection_makes_ground_navigable_and_obstacles_red():
|
||||
# Ground row at y=1.0 across z=0..1; an obstacle at y=0.2 (above ground).
|
||||
pts: list[tuple[float, float, float]] = []
|
||||
for x in np.linspace(-0.5, 0.5, 11):
|
||||
for z in np.linspace(0.0, 1.0, 11):
|
||||
pts.append((float(x), 1.0, float(z))) # floor
|
||||
# Standing obstacle column at (x=0, z=0.5)
|
||||
for y in np.linspace(0.2, 0.9, 8):
|
||||
pts.append((0.0, float(y), 0.5))
|
||||
vm = _vm_from_points(pts, voxel_size=0.05)
|
||||
grid = project_voxel_map_to_grid(vm, cell_size=0.1, obstacle_y_range=(-2.0, -0.1))
|
||||
# The obstacle column should yield at least one OBSTACLE cell.
|
||||
assert (grid.classes == OBSTACLE).sum() >= 1
|
||||
# The floor away from the obstacle should be NAVIGABLE.
|
||||
iz, ix = grid.world_to_cell(-0.4, 0.0)
|
||||
assert grid.classes[iz, ix] == NAVIGABLE
|
||||
# Everywhere outside the observed area should still be UNOBSERVED.
|
||||
assert (grid.classes == UNOBSERVED).any()
|
||||
|
||||
|
||||
def test_empty_voxelmap_projects_to_single_unobserved_cell():
|
||||
vm = VoxelMap()
|
||||
grid = project_voxel_map_to_grid(vm)
|
||||
assert grid.shape == (1, 1)
|
||||
assert grid.classes[0, 0] == UNOBSERVED
|
||||
|
||||
|
||||
def test_world_to_cell_and_back_roundtrip():
|
||||
classes = np.full((4, 6), NAVIGABLE, dtype=np.int8)
|
||||
grid = OccupancyGrid(classes=classes, cell_size=0.5, origin_x=-1.0, origin_z=2.0, ground_y=0.0)
|
||||
iz, ix = grid.world_to_cell(0.25, 3.4)
|
||||
x, z = grid.cell_to_world(iz, ix)
|
||||
# The recovered (x, z) should land inside the same cell.
|
||||
iz2, ix2 = grid.world_to_cell(x, z)
|
||||
assert (iz, ix) == (iz2, ix2)
|
||||
|
||||
|
||||
def test_astar_finds_straight_path_when_unblocked():
|
||||
classes = np.full((10, 10), NAVIGABLE, dtype=np.int8)
|
||||
grid = OccupancyGrid(classes=classes, cell_size=0.1, origin_x=0.0, origin_z=0.0, ground_y=0.0)
|
||||
path = astar(grid, (0.05, 0.05), (0.85, 0.85))
|
||||
assert path is not None
|
||||
assert len(path) >= 2
|
||||
# Should land near the goal.
|
||||
assert abs(path[-1][0] - 0.85) < 0.1 and abs(path[-1][1] - 0.85) < 0.1
|
||||
|
||||
|
||||
def test_astar_routes_around_obstacle_wall():
|
||||
# Vertical wall at column 5, rows 1..8.
|
||||
classes = np.full((10, 10), NAVIGABLE, dtype=np.int8)
|
||||
classes[1:9, 5] = OBSTACLE
|
||||
grid = OccupancyGrid(classes=classes, cell_size=0.1, origin_x=0.0, origin_z=0.0, ground_y=0.0)
|
||||
path = astar(grid, (0.05, 0.45), (0.95, 0.45))
|
||||
assert path is not None
|
||||
# The path must not pass through any obstacle cell.
|
||||
for x, z in path:
|
||||
iz, ix = grid.world_to_cell(x, z)
|
||||
assert classes[iz, ix] != OBSTACLE
|
||||
|
||||
|
||||
def test_astar_returns_none_when_no_path():
|
||||
# Wall that completely separates start from goal.
|
||||
classes = np.full((10, 10), NAVIGABLE, dtype=np.int8)
|
||||
classes[:, 5] = OBSTACLE
|
||||
grid = OccupancyGrid(classes=classes, cell_size=0.1, origin_x=0.0, origin_z=0.0, ground_y=0.0)
|
||||
path = astar(grid, (0.05, 0.5), (0.95, 0.5))
|
||||
assert path is None
|
||||
|
||||
|
||||
def test_astar_no_corner_cutting_through_obstacles():
|
||||
# Two diagonal obstacle cells that would let a naive A* squeeze through.
|
||||
classes = np.full((4, 4), NAVIGABLE, dtype=np.int8)
|
||||
classes[1, 2] = OBSTACLE
|
||||
classes[2, 1] = OBSTACLE
|
||||
grid = OccupancyGrid(classes=classes, cell_size=1.0, origin_x=0.0, origin_z=0.0, ground_y=0.0)
|
||||
path = astar(grid, (0.5, 0.5), (2.5, 2.5))
|
||||
assert path is not None
|
||||
# The path should NOT step (1, 1) → (2, 2) since that diagonal cuts the
|
||||
# obstacle corner. Verify by checking no two consecutive cells are a
|
||||
# diagonal move with both perpendicular cells blocked.
|
||||
cells = [grid.world_to_cell(x, z) for x, z in path]
|
||||
for prev, nxt in zip(cells, cells[1:], strict=False):
|
||||
diz = nxt[0] - prev[0]
|
||||
dix = nxt[1] - prev[1]
|
||||
if abs(diz) == 1 and abs(dix) == 1:
|
||||
assert grid.is_navigable(prev[0] + diz, prev[1]) and grid.is_navigable(prev[0], prev[1] + dix), (
|
||||
f"Corner cut detected at step {prev}->{nxt}"
|
||||
)
|
||||
|
||||
|
||||
def test_obstacle_inflation_grows_obstacle_class():
|
||||
classes = np.full((5, 5), NAVIGABLE, dtype=np.int8)
|
||||
classes[2, 2] = OBSTACLE
|
||||
# Test the private helper directly — we don't need a voxel map for this.
|
||||
from lerobot.navigation.occupancy import _inflate_obstacles
|
||||
|
||||
inflated = _inflate_obstacles(classes, radius=1)
|
||||
# 3x3 block now obstacle (around the single original cell).
|
||||
assert (inflated[1:4, 1:4] == OBSTACLE).all()
|
||||
# Far corner stays navigable.
|
||||
assert inflated[0, 0] == NAVIGABLE
|
||||
|
||||
|
||||
def test_find_frontier_cells_are_navigable_next_to_unobserved():
|
||||
classes = np.full((5, 5), UNOBSERVED, dtype=np.int8)
|
||||
classes[1:4, 1:4] = NAVIGABLE
|
||||
grid = OccupancyGrid(classes=classes, cell_size=0.1, origin_x=0.0, origin_z=0.0, ground_y=0.0)
|
||||
cells = find_frontier_cells(grid)
|
||||
# Every frontier cell is NAVIGABLE itself.
|
||||
for iz, ix in cells:
|
||||
assert classes[iz, ix] == NAVIGABLE
|
||||
# And every frontier cell has at least one UNOBSERVED 4-neighbour.
|
||||
for iz, ix in cells:
|
||||
adj_unobs = (
|
||||
(iz > 0 and classes[iz - 1, ix] == UNOBSERVED)
|
||||
or (iz < 4 and classes[iz + 1, ix] == UNOBSERVED)
|
||||
or (ix > 0 and classes[iz, ix - 1] == UNOBSERVED)
|
||||
or (ix < 4 and classes[iz, ix + 1] == UNOBSERVED)
|
||||
)
|
||||
assert adj_unobs
|
||||
|
||||
|
||||
def test_find_frontier_empty_when_no_unobserved():
|
||||
classes = np.full((5, 5), NAVIGABLE, dtype=np.int8)
|
||||
grid = OccupancyGrid(classes=classes, cell_size=0.1, origin_x=0.0, origin_z=0.0, ground_y=0.0)
|
||||
cells = find_frontier_cells(grid)
|
||||
assert cells.shape == (0, 2)
|
||||
|
||||
|
||||
def test_occupancy_to_rgb_returns_uint8_image():
|
||||
classes = np.array([[UNOBSERVED, NAVIGABLE, OBSTACLE]], dtype=np.int8)
|
||||
grid = OccupancyGrid(classes=classes, cell_size=0.1, origin_x=0.0, origin_z=0.0, ground_y=0.0)
|
||||
img = occupancy_to_rgb(grid)
|
||||
assert img.shape == (1, 3, 3)
|
||||
assert img.dtype == np.uint8
|
||||
# Obstacle cell is reddish.
|
||||
assert img[0, 2, 0] > img[0, 2, 1] and img[0, 2, 0] > img[0, 2, 2]
|
||||
|
||||
|
||||
def test_carving_makes_obstacle_vanish_on_next_projection():
|
||||
"""End-to-end: an object voxel becomes an obstacle; after we delete it
|
||||
from the VoxelMap (simulating carving), the next projection no longer
|
||||
flags that cell as OBSTACLE — that's what makes the obstacle map a
|
||||
cheap derived view."""
|
||||
pts = []
|
||||
for x in np.linspace(-0.5, 0.5, 11):
|
||||
for z in np.linspace(0.0, 1.0, 11):
|
||||
pts.append((float(x), 1.0, float(z))) # floor
|
||||
for y in np.linspace(0.2, 0.9, 8):
|
||||
pts.append((0.0, float(y), 0.5)) # obstacle column
|
||||
vm = _vm_from_points(pts, voxel_size=0.05)
|
||||
grid1 = project_voxel_map_to_grid(vm, cell_size=0.1)
|
||||
assert (grid1.classes == OBSTACLE).sum() >= 1
|
||||
|
||||
# Surgically drop every voxel in the obstacle band.
|
||||
cnt = vm._count.astype(np.float64).reshape(-1, 1) # noqa: SLF001
|
||||
means = vm._xyz_sum / cnt # noqa: SLF001
|
||||
keep = (means[:, 1] >= 0.95) | (means[:, 1] <= 0.05)
|
||||
vm._idx = vm._idx[keep] # noqa: SLF001
|
||||
vm._count = vm._count[keep] # noqa: SLF001
|
||||
vm._xyz_sum = vm._xyz_sum[keep] # noqa: SLF001
|
||||
vm._rgb_sum = vm._rgb_sum[keep] # noqa: SLF001
|
||||
vm._last_frame = vm._last_frame[keep] # noqa: SLF001
|
||||
vm._last_time = vm._last_time[keep] # noqa: SLF001
|
||||
vm._lookup = { # noqa: SLF001
|
||||
(int(vm._idx[i, 0]), int(vm._idx[i, 1]), int(vm._idx[i, 2])): i # noqa: SLF001
|
||||
for i in range(len(vm._idx)) # noqa: SLF001
|
||||
}
|
||||
|
||||
grid2 = project_voxel_map_to_grid(vm, cell_size=0.1)
|
||||
assert (grid2.classes == OBSTACLE).sum() == 0
|
||||
|
||||
|
||||
def test_astar_works_after_inflation():
|
||||
# Without inflation, robot can hug a 1-cell-wide wall; with inflation,
|
||||
# it has to take a wider detour.
|
||||
classes = np.full((11, 11), NAVIGABLE, dtype=np.int8)
|
||||
classes[5, 1:9] = OBSTACLE # horizontal wall
|
||||
grid_raw = OccupancyGrid(classes=classes.copy(), cell_size=0.1, origin_x=0.0, origin_z=0.0, ground_y=0.0)
|
||||
path_raw = astar(grid_raw, (0.45, 0.0), (0.45, 1.0))
|
||||
assert path_raw is not None # the wall has an opening at the edges
|
||||
from lerobot.navigation.occupancy import _inflate_obstacles
|
||||
|
||||
inflated = _inflate_obstacles(classes, radius=1)
|
||||
grid_inf = OccupancyGrid(classes=inflated, cell_size=0.1, origin_x=0.0, origin_z=0.0, ground_y=0.0)
|
||||
# After inflation the path is at least as long (often longer).
|
||||
path_inf = astar(grid_inf, (0.45, 0.0), (0.45, 1.0))
|
||||
if path_inf is not None:
|
||||
assert len(path_inf) >= len(path_raw)
|
||||
|
||||
|
||||
def test_obstacle_range_outside_voxel_y_produces_only_navigable():
|
||||
"""A range well above the actual voxel y values should classify the floor
|
||||
as NAVIGABLE only — no obstacles. (Replaces the prior sub-float32-epsilon
|
||||
test which exercised numpy's float-downcast quirk rather than the
|
||||
occupancy semantics.)"""
|
||||
pts = [(float(x), 1.0, float(z)) for x in [0, 0.1] for z in [0, 0.1]]
|
||||
vm = _vm_from_points(pts, voxel_size=0.05)
|
||||
# ground_y will be 1.0; this range looks for obstacles 5 m–10 m above
|
||||
# the floor, where there are none.
|
||||
grid = project_voxel_map_to_grid(vm, obstacle_y_range=(-10.0, -5.0))
|
||||
assert (grid.classes == OBSTACLE).sum() == 0
|
||||
assert (grid.classes == NAVIGABLE).sum() >= 1
|
||||
@@ -0,0 +1,149 @@
|
||||
#!/usr/bin/env python
|
||||
|
||||
# Copyright 2026 The HuggingFace Inc. team. All rights reserved.
|
||||
#
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
|
||||
"""Unit tests for ``VoxelMap`` — geometry-only behavior (M3)."""
|
||||
|
||||
# ruff: noqa: N803, N806 — H, W, D: conventional array-dimension names
|
||||
from __future__ import annotations
|
||||
|
||||
import numpy as np
|
||||
import pytest
|
||||
|
||||
from lerobot.navigation.voxel_map import VoxelMap
|
||||
|
||||
|
||||
def _scatter(points: np.ndarray, color: tuple[int, int, int] = (200, 100, 50)) -> tuple[np.ndarray, ...]:
|
||||
"""Helper: build (points, rgb, conf) arrays from a list of xyz coords."""
|
||||
rgb = np.tile(np.array(color, dtype=np.uint8), (len(points), 1))
|
||||
conf = np.ones(len(points), dtype=np.float32)
|
||||
return points.astype(np.float32), rgb, conf
|
||||
|
||||
|
||||
def test_initially_empty():
|
||||
vm = VoxelMap(voxel_size=0.05)
|
||||
assert len(vm) == 0
|
||||
snap = vm.snapshot()
|
||||
assert snap.xyz.shape == (0, 3)
|
||||
assert snap.rgb.shape == (0, 3)
|
||||
|
||||
|
||||
def test_voxel_size_must_be_positive():
|
||||
with pytest.raises(ValueError):
|
||||
VoxelMap(voxel_size=0.0)
|
||||
with pytest.raises(ValueError):
|
||||
VoxelMap(voxel_size=-0.1)
|
||||
|
||||
|
||||
def test_single_point_creates_one_voxel():
|
||||
vm = VoxelMap(voxel_size=0.1)
|
||||
pts, rgb, conf = _scatter(np.array([[0.123, 0.456, 0.789]]))
|
||||
stats = vm.add(pts, rgb, conf, frame=0, t=0.0)
|
||||
assert stats == type(stats)(n_voxels=1, n_added=1, n_updated=0)
|
||||
assert len(vm) == 1
|
||||
snap = vm.snapshot()
|
||||
np.testing.assert_allclose(snap.xyz[0], [0.123, 0.456, 0.789], atol=1e-5)
|
||||
|
||||
|
||||
def test_points_in_same_voxel_collapse_and_average():
|
||||
vm = VoxelMap(voxel_size=0.1)
|
||||
# Two points inside the voxel [0.0, 0.1) on each axis.
|
||||
pts, rgb, conf = _scatter(np.array([[0.01, 0.01, 0.01], [0.09, 0.09, 0.09]]))
|
||||
stats = vm.add(pts, rgb, conf, frame=0, t=0.0)
|
||||
assert stats.n_voxels == 1
|
||||
assert stats.n_added == 1
|
||||
snap = vm.snapshot()
|
||||
np.testing.assert_allclose(snap.xyz[0], [0.05, 0.05, 0.05], atol=1e-5)
|
||||
assert int(snap.count[0]) == 2
|
||||
|
||||
|
||||
def test_second_keyframe_updates_running_mean():
|
||||
vm = VoxelMap(voxel_size=0.1)
|
||||
pts1, rgb1, conf1 = _scatter(np.array([[0.02, 0.02, 0.02]]), color=(100, 100, 100))
|
||||
vm.add(pts1, rgb1, conf1, frame=0, t=0.0)
|
||||
pts2, rgb2, conf2 = _scatter(np.array([[0.08, 0.08, 0.08]]), color=(200, 200, 200))
|
||||
stats = vm.add(pts2, rgb2, conf2, frame=1, t=0.5)
|
||||
assert stats.n_added == 0
|
||||
assert stats.n_updated == 1
|
||||
snap = vm.snapshot()
|
||||
# Mean position = (0.02 + 0.08) / 2 = 0.05; mean color = 150.
|
||||
np.testing.assert_allclose(snap.xyz[0], [0.05, 0.05, 0.05], atol=1e-5)
|
||||
np.testing.assert_allclose(snap.rgb[0], [150, 150, 150], atol=1)
|
||||
assert int(snap.last_frame[0]) == 1
|
||||
assert float(snap.last_time[0]) == pytest.approx(0.5)
|
||||
|
||||
|
||||
def test_conf_gate_drops_low_confidence_pixels():
|
||||
vm = VoxelMap(voxel_size=0.1)
|
||||
pts = np.array([[0.0, 0.0, 0.0], [1.0, 1.0, 1.0]], dtype=np.float32)
|
||||
rgb = np.array([[10, 20, 30], [40, 50, 60]], dtype=np.uint8)
|
||||
conf = np.array([0.9, 0.1], dtype=np.float32)
|
||||
stats = vm.add(pts, rgb, conf, frame=0, t=0.0, conf_thresh=0.5)
|
||||
assert stats.n_voxels == 1
|
||||
snap = vm.snapshot()
|
||||
np.testing.assert_allclose(snap.xyz[0], [0.0, 0.0, 0.0], atol=1e-5)
|
||||
np.testing.assert_allclose(snap.rgb[0], [10, 20, 30], atol=1)
|
||||
|
||||
|
||||
def test_quantization_negative_coordinates():
|
||||
vm = VoxelMap(voxel_size=0.1)
|
||||
pts, rgb, conf = _scatter(np.array([[-0.05, -0.15, -0.25]]))
|
||||
stats = vm.add(pts, rgb, conf, frame=0, t=0.0)
|
||||
assert stats.n_voxels == 1
|
||||
# floor(-0.05/0.1) = floor(-0.5) = -1 (voxel covers [-0.1, 0.0)).
|
||||
# Just check the mean equals the input single point.
|
||||
snap = vm.snapshot()
|
||||
np.testing.assert_allclose(snap.xyz[0], [-0.05, -0.15, -0.25], atol=1e-5)
|
||||
|
||||
|
||||
def test_image_shaped_input_is_flattened():
|
||||
"""``add`` accepts (H, W, 3) point arrays — typical Pi3X output shape."""
|
||||
vm = VoxelMap(voxel_size=0.5)
|
||||
H, W = 4, 4
|
||||
pts = np.zeros((H, W, 3), dtype=np.float32)
|
||||
pts[..., 0] = np.linspace(0, 5, W)[None, :] # 16 unique x values? No, 4.
|
||||
rgb = np.full((H, W, 3), 128, dtype=np.uint8)
|
||||
conf = np.ones((H, W), dtype=np.float32)
|
||||
stats = vm.add(pts, rgb, conf, frame=0, t=0.0)
|
||||
# All x values land in voxel slots at 0, 1, 2, 3, 4 (different voxels at
|
||||
# 0.5m size); each row of the image contributes the same 4 unique voxels,
|
||||
# collapsed within the keyframe — but actually voxel indices depend on x.
|
||||
# The point of this test is just that flattening works without raising.
|
||||
assert stats.n_voxels >= 1
|
||||
assert stats.n_voxels <= H * W
|
||||
|
||||
|
||||
def test_nonfinite_points_are_dropped():
|
||||
vm = VoxelMap(voxel_size=0.1)
|
||||
pts = np.array(
|
||||
[[0.0, 0.0, 0.0], [np.nan, 0.0, 0.0], [0.0, np.inf, 0.0]],
|
||||
dtype=np.float32,
|
||||
)
|
||||
rgb = np.full((3, 3), 100, dtype=np.uint8)
|
||||
conf = np.ones(3, dtype=np.float32)
|
||||
stats = vm.add(pts, rgb, conf, frame=0, t=0.0)
|
||||
assert stats.n_voxels == 1
|
||||
|
||||
|
||||
def test_snapshot_rgb_clipped_to_uint8():
|
||||
"""If RGB sums accumulate to > 255 per channel, the snapshot mean is still
|
||||
a clean uint8."""
|
||||
vm = VoxelMap(voxel_size=0.1)
|
||||
pts, rgb, conf = _scatter(np.array([[0.0, 0.0, 0.0]]), color=(255, 255, 255))
|
||||
vm.add(pts, rgb, conf, frame=0, t=0.0)
|
||||
vm.add(pts, rgb, conf, frame=1, t=0.5)
|
||||
snap = vm.snapshot()
|
||||
assert snap.rgb.dtype == np.uint8
|
||||
np.testing.assert_array_equal(snap.rgb[0], [255, 255, 255])
|
||||
@@ -0,0 +1,217 @@
|
||||
#!/usr/bin/env python
|
||||
|
||||
# Copyright 2026 The HuggingFace Inc. team. All rights reserved.
|
||||
#
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
|
||||
"""Unit tests for ``VoxelMap.carve`` — DynaMem-style free-space removal (M4)."""
|
||||
|
||||
# ruff: noqa: N803, N806 — H, W, D: conventional array-dimension names
|
||||
from __future__ import annotations
|
||||
|
||||
import math
|
||||
|
||||
import numpy as np
|
||||
import pytest
|
||||
|
||||
from lerobot.navigation.voxel_map import CarveResult, VoxelMap
|
||||
|
||||
|
||||
def _identity_pose() -> np.ndarray:
|
||||
"""Camera-to-world identity (camera == world)."""
|
||||
return np.eye(4, dtype=np.float64)
|
||||
|
||||
|
||||
def _shifted_pose(tx: float = 0.0, ty: float = 0.0, tz: float = 0.0) -> np.ndarray:
|
||||
p = np.eye(4, dtype=np.float64)
|
||||
p[:3, 3] = (tx, ty, tz)
|
||||
return p
|
||||
|
||||
|
||||
def _solid_depth_view(H: int, W: int, depth: float, conf: float = 1.0) -> tuple[np.ndarray, np.ndarray]:
|
||||
"""A synthetic Pi3X view: every pixel sees a surface at ``depth`` meters."""
|
||||
fov_deg = 90.0
|
||||
focal = W / (2.0 * math.tan(math.radians(fov_deg) / 2.0))
|
||||
cx, cy = (W - 1) / 2.0, (H - 1) / 2.0
|
||||
us, vs = np.meshgrid(np.arange(W), np.arange(H))
|
||||
# local_points[v,u] = depth * (K^-1 @ [u,v,1]); z = depth.
|
||||
x = (us - cx) * depth / focal
|
||||
y = (vs - cy) * depth / focal
|
||||
z = np.full_like(x, depth, dtype=np.float64)
|
||||
local = np.stack([x, y, z], axis=-1).astype(np.float32)
|
||||
return local, np.full((H, W), conf, dtype=np.float32), focal
|
||||
|
||||
|
||||
def _seed_voxel(vm: VoxelMap, xyz: tuple[float, float, float], frame: int = 0) -> None:
|
||||
pts = np.asarray([xyz], dtype=np.float32)
|
||||
rgb = np.full((1, 3), 200, dtype=np.uint8)
|
||||
conf = np.ones(1, dtype=np.float32)
|
||||
vm.add(pts, rgb, conf, frame=frame, t=float(frame) * 0.5)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_empty_map_returns_zero():
|
||||
vm = VoxelMap(voxel_size=0.1)
|
||||
local, conf, focal = _solid_depth_view(32, 32, depth=5.0)
|
||||
result = vm.carve(local, conf, _identity_pose(), focal_px=focal, frame=0, t=0.0)
|
||||
assert isinstance(result, CarveResult)
|
||||
assert result.n_removed == 0
|
||||
assert result.removed_xyz.shape == (0, 3)
|
||||
|
||||
|
||||
def test_voxel_in_front_of_surface_is_removed():
|
||||
"""Voxel at z=2 m, observed surface at z=5 m, margin 0.05: free space."""
|
||||
vm = VoxelMap(voxel_size=0.1)
|
||||
_seed_voxel(vm, (0.0, 0.0, 2.0))
|
||||
assert len(vm) == 1
|
||||
local, conf, focal = _solid_depth_view(32, 32, depth=5.0)
|
||||
result = vm.carve(local, conf, _identity_pose(), focal_px=focal, frame=1, t=0.5)
|
||||
assert result.n_removed == 1
|
||||
assert len(vm) == 0
|
||||
assert result.removed_xyz.shape == (1, 3)
|
||||
|
||||
|
||||
def test_voxel_at_surface_is_kept():
|
||||
"""Voxel at z = 5.0 m, surface at 5.0 m — d is NOT < D - margin."""
|
||||
vm = VoxelMap(voxel_size=0.1)
|
||||
_seed_voxel(vm, (0.0, 0.0, 5.0))
|
||||
local, conf, focal = _solid_depth_view(32, 32, depth=5.0)
|
||||
result = vm.carve(local, conf, _identity_pose(), focal_px=focal, frame=1, t=0.5, margin=0.05)
|
||||
assert result.n_removed == 0
|
||||
assert len(vm) == 1
|
||||
|
||||
|
||||
def test_voxel_behind_surface_is_kept():
|
||||
"""Voxel at z=10 m, surface at z=5 m: voxel is occluded, NOT free space."""
|
||||
vm = VoxelMap(voxel_size=0.1)
|
||||
_seed_voxel(vm, (0.0, 0.0, 10.0))
|
||||
local, conf, focal = _solid_depth_view(32, 32, depth=5.0)
|
||||
result = vm.carve(local, conf, _identity_pose(), focal_px=focal, frame=1, t=0.5)
|
||||
assert result.n_removed == 0
|
||||
|
||||
|
||||
def test_voxel_behind_camera_is_kept():
|
||||
"""A voxel with z<=0 in the camera frame can't be seen — must not be carved."""
|
||||
vm = VoxelMap(voxel_size=0.1)
|
||||
_seed_voxel(vm, (0.0, 0.0, -1.0))
|
||||
local, conf, focal = _solid_depth_view(32, 32, depth=5.0)
|
||||
result = vm.carve(local, conf, _identity_pose(), focal_px=focal, frame=1, t=0.5)
|
||||
assert result.n_removed == 0
|
||||
|
||||
|
||||
def test_voxel_out_of_view_is_kept():
|
||||
"""A voxel inside no pixel's frustum should be left alone."""
|
||||
vm = VoxelMap(voxel_size=0.1)
|
||||
# Way off to the side — projects outside the 32x32 image at 90° HFOV.
|
||||
_seed_voxel(vm, (50.0, 0.0, 2.0))
|
||||
local, conf, focal = _solid_depth_view(32, 32, depth=5.0)
|
||||
result = vm.carve(local, conf, _identity_pose(), focal_px=focal, frame=1, t=0.5)
|
||||
assert result.n_removed == 0
|
||||
|
||||
|
||||
def test_low_confidence_blocks_carve():
|
||||
"""If conf at the projected pixel is below threshold, don't remove."""
|
||||
vm = VoxelMap(voxel_size=0.1)
|
||||
_seed_voxel(vm, (0.0, 0.0, 2.0))
|
||||
local, conf, focal = _solid_depth_view(32, 32, depth=5.0, conf=0.2)
|
||||
result = vm.carve(local, conf, _identity_pose(), focal_px=focal, frame=1, t=0.5, conf_thresh=0.5)
|
||||
assert result.n_removed == 0
|
||||
|
||||
|
||||
def test_invalid_depth_blocks_carve():
|
||||
"""NaN / non-positive depth at the projected pixel must not trigger carve."""
|
||||
vm = VoxelMap(voxel_size=0.1)
|
||||
_seed_voxel(vm, (0.0, 0.0, 2.0))
|
||||
local, conf, focal = _solid_depth_view(32, 32, depth=5.0)
|
||||
local[:, :, 2] = np.nan
|
||||
result = vm.carve(local, conf, _identity_pose(), focal_px=focal, frame=1, t=0.5)
|
||||
assert result.n_removed == 0
|
||||
|
||||
|
||||
def test_margin_protects_near_surface():
|
||||
"""A voxel 3 cm in front of a 5 m surface, margin 5 cm: NOT carved."""
|
||||
vm = VoxelMap(voxel_size=0.1)
|
||||
_seed_voxel(vm, (0.0, 0.0, 5.0 - 0.03))
|
||||
local, conf, focal = _solid_depth_view(32, 32, depth=5.0)
|
||||
result = vm.carve(local, conf, _identity_pose(), focal_px=focal, frame=1, t=0.5, margin=0.05)
|
||||
assert result.n_removed == 0
|
||||
|
||||
|
||||
def test_margin_zero_carves_near_surface():
|
||||
"""Same setup, margin 0: now the 3 cm gap counts as free space."""
|
||||
vm = VoxelMap(voxel_size=0.1)
|
||||
_seed_voxel(vm, (0.0, 0.0, 5.0 - 0.03))
|
||||
local, conf, focal = _solid_depth_view(32, 32, depth=5.0)
|
||||
result = vm.carve(local, conf, _identity_pose(), focal_px=focal, frame=1, t=0.5, margin=0.0)
|
||||
assert result.n_removed == 1
|
||||
|
||||
|
||||
def test_carve_compacts_arrays_and_lookup():
|
||||
"""After carving some but not all voxels, internal storage stays consistent."""
|
||||
vm = VoxelMap(voxel_size=0.1)
|
||||
# Three voxels: in front (will be carved), at surface (kept), to the side (kept).
|
||||
_seed_voxel(vm, (0.0, 0.0, 2.0))
|
||||
_seed_voxel(vm, (0.0, 0.0, 5.0))
|
||||
_seed_voxel(vm, (50.0, 0.0, 5.0))
|
||||
assert len(vm) == 3
|
||||
local, conf, focal = _solid_depth_view(32, 32, depth=5.0)
|
||||
result = vm.carve(local, conf, _identity_pose(), focal_px=focal, frame=1, t=0.5)
|
||||
assert result.n_removed == 1
|
||||
assert len(vm) == 2
|
||||
# All internal arrays must agree on the new length.
|
||||
assert vm._idx.shape == (2, 3) # noqa: SLF001
|
||||
assert vm._count.shape == (2,) # noqa: SLF001
|
||||
assert len(vm._lookup) == 2 # noqa: SLF001
|
||||
# Lookup rows must point at valid indices in the new arrays.
|
||||
for row in vm._lookup.values(): # noqa: SLF001
|
||||
assert 0 <= row < 2
|
||||
# A subsequent `add` to a removed voxel must work cleanly (revives it).
|
||||
pts = np.asarray([[0.0, 0.0, 2.0]], dtype=np.float32)
|
||||
rgb = np.full((1, 3), 100, dtype=np.uint8)
|
||||
conf1 = np.ones(1, dtype=np.float32)
|
||||
add_stats = vm.add(pts, rgb, conf1, frame=2, t=1.0)
|
||||
assert add_stats.n_added == 1
|
||||
assert len(vm) == 3
|
||||
|
||||
|
||||
def test_carve_shape_validation():
|
||||
vm = VoxelMap(voxel_size=0.1)
|
||||
_seed_voxel(vm, (0.0, 0.0, 2.0))
|
||||
bad_local = np.zeros((32, 32, 2), dtype=np.float32)
|
||||
conf = np.ones((32, 32), dtype=np.float32)
|
||||
with pytest.raises(ValueError, match=r"H, W, 3"):
|
||||
vm.carve(bad_local, conf, _identity_pose(), focal_px=16.0, frame=0, t=0.0)
|
||||
|
||||
local = np.zeros((32, 32, 3), dtype=np.float32)
|
||||
bad_conf = np.ones((16, 32), dtype=np.float32)
|
||||
with pytest.raises(ValueError, match=r"conf shape"):
|
||||
vm.carve(local, bad_conf, _identity_pose(), focal_px=16.0, frame=0, t=0.0)
|
||||
|
||||
bad_pose = np.eye(3)
|
||||
with pytest.raises(ValueError, match=r"pose must be"):
|
||||
vm.carve(local, conf, bad_pose, focal_px=16.0, frame=0, t=0.0)
|
||||
|
||||
|
||||
def test_carve_with_translated_camera():
|
||||
"""If the camera has moved, world-space voxels must be transformed correctly
|
||||
before the free-space test."""
|
||||
vm = VoxelMap(voxel_size=0.1)
|
||||
# Voxel at world position (10, 0, 2). With camera at world (10, 0, 0),
|
||||
# the voxel is 2 m in front of the camera's local +Z; surface at 5 m → carve.
|
||||
_seed_voxel(vm, (10.0, 0.0, 2.0))
|
||||
pose = _shifted_pose(tx=10.0, ty=0.0, tz=0.0)
|
||||
local, conf, focal = _solid_depth_view(32, 32, depth=5.0)
|
||||
result = vm.carve(local, conf, pose, focal_px=focal, frame=1, t=0.5)
|
||||
assert result.n_removed == 1
|
||||
@@ -0,0 +1,99 @@
|
||||
#!/usr/bin/env python
|
||||
|
||||
# Copyright 2026 The HuggingFace Inc. team. All rights reserved.
|
||||
#
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
|
||||
"""Unit tests for the C2 scene-mutation helper on VoxelMap."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import numpy as np
|
||||
|
||||
from lerobot.navigation.voxel_map import VoxelMap
|
||||
|
||||
|
||||
def test_remove_voxels_in_box_zero_when_empty():
|
||||
vm = VoxelMap()
|
||||
assert vm.remove_voxels_in_box((-1, -1, -1), (1, 1, 1)) == 0
|
||||
|
||||
|
||||
def test_remove_voxels_in_box_only_deletes_inside():
|
||||
vm = VoxelMap(voxel_size=0.1)
|
||||
pts = np.array(
|
||||
[
|
||||
[[0.0, 0.0, 0.0]], # inside
|
||||
[[0.1, 0.0, 0.0]], # inside
|
||||
[[2.0, 0.0, 0.0]], # outside
|
||||
],
|
||||
dtype=np.float64,
|
||||
)
|
||||
rgb = np.full((3, 1, 3), 200, dtype=np.uint8)
|
||||
conf = np.ones((3, 1), dtype=np.float32)
|
||||
vm.add(pts, rgb, conf, frame=0, t=0.0)
|
||||
assert len(vm) == 3
|
||||
|
||||
n = vm.remove_voxels_in_box((-0.05, -0.05, -0.05), (0.15, 0.05, 0.05))
|
||||
assert n == 2
|
||||
assert len(vm) == 1
|
||||
snap = vm.snapshot()
|
||||
np.testing.assert_allclose(snap.xyz[0], [2.0, 0.0, 0.0], atol=1e-3)
|
||||
|
||||
|
||||
def test_remove_voxels_keeps_feature_arrays_aligned():
|
||||
vm = VoxelMap(voxel_size=0.1)
|
||||
pts = np.array([[[0.0, 0.0, 0.0]], [[2.0, 0.0, 0.0]]], dtype=np.float64)
|
||||
rgb = np.full((2, 1, 3), 200, dtype=np.uint8)
|
||||
conf = np.ones((2, 1), dtype=np.float32)
|
||||
feat = np.array([[[1.0, 0.0, 0.0, 0.0]], [[0.0, 1.0, 0.0, 0.0]]], dtype=np.float16)
|
||||
vm.add(pts, rgb, conf, frame=0, t=0.0, feat_map=feat)
|
||||
assert len(vm) == 2
|
||||
assert vm._feat_sum.shape == (2, 4) # noqa: SLF001
|
||||
|
||||
vm.remove_voxels_in_box((-0.5, -0.5, -0.5), (0.5, 0.5, 0.5))
|
||||
assert len(vm) == 1
|
||||
# Feature arrays now have length 1 — matches _count.
|
||||
assert vm._feat_sum.shape == (1, 4) # noqa: SLF001
|
||||
snap = vm.snapshot(include_features=True)
|
||||
# Surviving voxel had vector [0, 1, 0, 0]; normalized stays the same.
|
||||
assert snap.feat is not None
|
||||
np.testing.assert_allclose(
|
||||
snap.feat[0].astype(np.float32),
|
||||
[0.0, 1.0, 0.0, 0.0],
|
||||
atol=1e-3,
|
||||
)
|
||||
|
||||
|
||||
def test_remove_voxels_compacts_lookup():
|
||||
"""After deletion the dict→row map must still point to the right rows."""
|
||||
vm = VoxelMap(voxel_size=0.1)
|
||||
pts = np.array(
|
||||
[[[0.0, 0.0, 0.0]], [[2.0, 0.0, 0.0]], [[4.0, 0.0, 0.0]]],
|
||||
dtype=np.float64,
|
||||
)
|
||||
rgb = np.full((3, 1, 3), 200, dtype=np.uint8)
|
||||
conf = np.ones((3, 1), dtype=np.float32)
|
||||
vm.add(pts, rgb, conf, frame=0, t=0.0)
|
||||
|
||||
vm.remove_voxels_in_box((1.5, -0.5, -0.5), (2.5, 0.5, 0.5)) # delete the middle
|
||||
assert len(vm) == 2
|
||||
|
||||
# Adding a new voxel at one of the surviving positions should UPDATE
|
||||
# (not append), which only works if the lookup row indices are correct.
|
||||
new_pt = np.array([[[0.0, 0.0, 0.0]]], dtype=np.float64)
|
||||
stats = vm.add(
|
||||
new_pt, np.full((1, 1, 3), 50, dtype=np.uint8), np.ones((1, 1), dtype=np.float32), frame=1, t=1.0
|
||||
)
|
||||
assert stats.n_added == 0
|
||||
assert stats.n_updated == 1
|
||||
assert len(vm) == 2
|
||||
Reference in New Issue
Block a user