toaster / tests /test_segment.py
SmaugC137's picture
Deploy Toaster demo to HF Space — procedural natural-terrain sample
b1390b1
Raw
History Blame Contribute Delete
7.35 kB
from __future__ import annotations
import importlib.util
import numpy as np
import pytest
from toaster.core import PointCloud, Selection
from toaster.segment import (
FunctionSegmenter,
ModelSegmenter,
available_segmenters,
get_segmenter,
segmenter_specs,
)
# CSF is an optional extra; its segmenter is only registered when the package is there.
needs_csf = pytest.mark.skipif(
importlib.util.find_spec("CSF") is None,
reason="cloth-simulation-filter not installed",
)
def test_dbscan_finds_two_clusters(two_clusters):
grouping = get_segmenter("dbscan", eps=0.5, min_samples=5).segment(two_clusters)
assert grouping.n_groups == 2
assert grouping.n == two_clusters.n
assert grouping.source == "dbscan"
assert grouping.params["eps"] == 0.5
def test_segmenter_scoped_to_selection_marks_rest_noise(two_clusters):
# Restrict to the first blob only; the rest must be noise (-1).
sel = Selection.from_indices(np.arange(50), two_clusters.n)
grouping = get_segmenter("dbscan", eps=0.5, min_samples=5).segment(two_clusters, sel)
assert np.all(grouping.group_id[50:] == -1)
assert grouping.n_groups == 1
def test_clusterers_on_one_point_return_all_noise(two_clusters):
# A one-point selection must not crash the clusterer (sklearn raises on
# n_samples=1); it yields an empty grouping instead.
one = Selection.from_indices(np.array([0]), two_clusters.n)
for name in ("dbscan", "hdbscan"):
grouping = get_segmenter(name).segment(two_clusters, one)
assert grouping.n_groups == 0
assert np.all(grouping.group_id == -1)
def test_registry_lists_builtins():
names = available_segmenters()
assert "dbscan" in names and "hdbscan" in names
def test_function_segmenter(two_clusters):
seg = FunctionSegmenter(lambda xyz: (xyz[:, 0] > 5).astype(int), name="split_x")
grouping = seg.segment(two_clusters)
assert grouping.n_groups == 2
assert grouping.source == "split_x"
def test_registry_has_the_new_algorithms():
names = available_segmenters()
for n in ["kmeans", "kmedoids", "agglomerative", "optics", "meanshift",
"ransac_ground", "ground_grid"]: # fmt: skip
assert n in names
def test_segmenter_specs_carry_params():
specs = {s["name"]: s["params"] for s in segmenter_specs()}
assert {p["name"] for p in specs["dbscan"]} == {"eps", "min_samples"}
assert specs["kmeans"][0]["name"] == "n_clusters"
assert specs["ground_grid"][0]["type"] == "float"
@pytest.mark.parametrize("name", ["kmeans", "kmedoids", "agglomerative"])
def test_partitioning_clusterers_make_k_groups(name, two_clusters):
grouping = get_segmenter(name, n_clusters=2).segment(two_clusters)
assert grouping.n_groups == 2
@pytest.fixture
def ground_scene():
rng = np.random.default_rng(1)
ground = rng.uniform([-5, -5, -0.02], [5, 5, 0.02], (300, 3))
obstacle = rng.uniform([0, 0, 1.0], [1, 1, 2.0], (60, 3))
return PointCloud(np.vstack([ground, obstacle]).astype(np.float32))
@pytest.mark.parametrize("name", ["ground_grid", "ransac_ground"])
def test_ground_detection_splits_and_suggests(name, ground_scene):
grouping = get_segmenter(name).segment(ground_scene)
# Group 0 = ground, group 1 = non-ground, suggested -> traversable / obstacle.
assert grouping.suggested_labels == {0: 1, 1: 2}
assert (grouping.group_id[:300] == 0).all() # the flat plane is ground
assert (grouping.group_id[300:] == 1).all() # the raised box is non-ground
def _tilt(xyz, deg):
"""Rotate a scene about X so gravity no longer points along +Z; return (xyz, up)."""
a = np.radians(deg)
rot = np.array([[1, 0, 0], [0, np.cos(a), -np.sin(a)], [0, np.sin(a), np.cos(a)]])
up = rot @ np.array([0.0, 0.0, 1.0])
return (xyz @ rot.T).astype(np.float32), up.tolist()
@pytest.mark.parametrize("name", ["ground_grid", pytest.param("csf", marks=needs_csf)])
def test_z_based_ground_filters_honour_up_on_tilted_scene(name, ground_scene):
# ground_grid and CSF both key off Z; on a tipped scene the given up vector
# lets them recover the ground a naive +Z assumption would miss.
tilted, up = _tilt(ground_scene.xyz, 40.0)
cloud = PointCloud(tilted)
aware = get_segmenter(name, up=up).segment(cloud)
assert (aware.group_id[:300] == 0).mean() > 0.9 # ground recovered
assert (aware.group_id[300:] == 1).mean() > 0.7 # obstacle kept separate
def test_ground_grid_without_up_misreads_tilted_scene(ground_scene):
tilted, _ = _tilt(ground_scene.xyz, 40.0)
naive = get_segmenter("ground_grid").segment(PointCloud(tilted)) # assumes cloud +Z
assert (naive.group_id[:300] == 0).mean() < 0.9 # the slope confuses Z-binning
def test_ransac_with_up_locks_onto_ground_not_largest_plane():
# A small horizontal ground and a *bigger* vertical wall. Plain RANSAC takes
# the wall (more inliers); with an up hint it must keep the horizontal ground.
rng = np.random.default_rng(0)
ground = np.c_[rng.uniform(-5, 5, 300), rng.uniform(-5, 5, 300), np.zeros(300)]
wall = np.c_[np.full(700, 4.0), rng.uniform(-5, 5, 700), rng.uniform(0, 5, 700)]
cloud = PointCloud(np.vstack([ground, wall]).astype(np.float32))
is_ground = np.r_[np.ones(300, bool), np.zeros(700, bool)]
res = get_segmenter("ransac_ground", threshold=0.1, iterations=400, up=[0, 0, 1]).segment(cloud)
pred = res.group_id == 0
assert pred[is_ground].mean() > 0.9 # the horizontal ground is found
assert pred[~is_ground].mean() < 0.1 # the bigger vertical wall is not "ground"
def test_segmenter_specs_flag_gravity_for_ground_filters():
gravity = {s["name"]: s["gravity"] for s in segmenter_specs()}
assert gravity["ransac_ground"] and gravity["ground_grid"]
if "csf" in gravity: # only registered when cloth-simulation-filter is installed
assert gravity["csf"]
assert not gravity["dbscan"] and not gravity["kmeans"]
def test_bad_up_vector_is_rejected():
with pytest.raises(ValueError):
get_segmenter("ground_grid", up=[0, 0, 0]) # zero vector has no direction
with pytest.raises(ValueError):
get_segmenter("ransac_ground", up=[1, 2]) # not a 3-vector
def test_model_segmenter_attaches_suggested_labels(two_clusters):
seg = ModelSegmenter(lambda xyz: np.where(xyz[:, 0] > 5, 2, 1), name="fake_nn")
grouping = seg.segment(two_clusters)
assert grouping.suggested_labels == {1: 1, 2: 2}
def test_model_segmenter_passes_features(two_clusters):
seen = {}
def predict(points):
seen["shape"] = points.shape
return np.where(points[:, 0] > 5, 2, 1)
# With intensity requested, the model receives [x, y, z, intensity].
ModelSegmenter(predict, name="nn", feature_keys=["intensity"]).segment(two_clusters)
assert seen["shape"] == (two_clusters.n, 4)
def test_register_model_appears_in_app_and_runs(two_clusters):
from toaster.segment import register_model
register_model("toy_net", lambda p: np.where(p[:, 0] > 5, 2, 1), feature_keys=["intensity"])
assert "toy_net" in available_segmenters()
# Constructible with no params (how the app's panel instantiates it).
grouping = get_segmenter("toy_net").segment(two_clusters)
assert grouping.n_groups == 2
assert grouping.suggested_labels == {1: 1, 2: 2}