dd5ac669e5
Today's session worked across the full Webots delivery stack — found and
fixed a cluster of bugs blocking the BC/RL transfer, then explored
training-side mitigations for the residual perception gap.
Bug fixes:
- Makefile FP_RATE default 2.0 → 0.0: BC demos used fp_rate=0 but RL
fine-tune defaulted to fp_rate=2, poisoning the BC obs distribution
and stalling PPO at 0% success across 1.46M+ steps.
- controllers/{shepherd_dog,sheep}/runtime.ini: Webots was launching
controllers under system python3 (no numpy) and they were crashing
silently. Pinned to the conda tir env.
- herding/config.py HERDING_WEBOTS preset: pen_latch_depth 0.5 → 2.0,
max_new_tracks_per_step 3 → 1, static_reject 0.8 → 1.2. Stops phantom
FPs near the gate from latching as permanently-penned tracks.
- herding/perception/sheep_tracker.py: penned tracks now decay at
forget_steps × 8 instead of living forever. Adds get_positions
min_freshness filter for deploy-time use.
Training/eval matches deployment:
- training/bc/collect.py: --dagger-policy flag for DAgger rollouts
(policy drives, teacher labels) + --use-webots-preset for matched
140° tracker + DR config.
- controllers/shepherd_dog/shepherd_dog.py: scan-fallback (0, 0.6) when
BC/RL sees empty sheep_positions — recovers from FOV gaps.
Tooling:
- tools/dagger_round.sh: one-shot DAgger round (collect + concat + bc).
- tools/webots_sweep_gt.sh: full sweep with HERDING_USE_GT=1 for the
perception-gap diagnosis matrix.
- protos/ShepherdDog360.proto: 360° FOV variant for the FOV-ablation
comparison. Canonical proto stays at 140° per project spec.
Artifacts: v1 BC/RL policies for all 4 (drive × world) combos trained
in clean gym (success: diff/field 90-100%, diff/round 58%, mec/field
60-100%, mec/round 50-100%). DAgger r1/r2 BCs for diff/field show
12%→38% progression on gym HERDING_WEBOTS proxy but did not close
to actual Webots LiDAR (0/5 throughout). Next: LSTM policy or
learned tracker per the project-state memory.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
243 lines
8.6 KiB
Python
243 lines
8.6 KiB
Python
"""Cluster a 2D LiDAR scan into world-frame sheep position estimates.
|
|
|
|
Pipeline:
|
|
|
|
ranges (N,) → hit mask → world-frame points
|
|
│
|
|
▼
|
|
adjacency clustering (gap > GAP_THRESHOLD
|
|
starts a new cluster, walking rays in
|
|
angular order)
|
|
│
|
|
▼
|
|
centroid + span + region + structure filters
|
|
│
|
|
▼
|
|
list of (x, y) detections
|
|
|
|
The downstream tracker handles association across frames.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import math
|
|
from typing import TYPE_CHECKING
|
|
|
|
import numpy as np
|
|
|
|
if TYPE_CHECKING:
|
|
from herding.config import DetectionConfig, LidarConfig
|
|
|
|
from herding.world.geometry import (
|
|
FIELD_SHAPE, FIELD_ROUND_R,
|
|
FIELD_X, FIELD_Y, GATE_X, GATE_Y,
|
|
PEN_X, PEN_Y,
|
|
)
|
|
from herding.perception.lidar_sim import (
|
|
LIDAR_FOV, LIDAR_MAX_RANGE, LIDAR_N_RAYS, SHEEP_RADIUS, POST_RADIUS,
|
|
ray_angles,
|
|
)
|
|
|
|
|
|
GAP_THRESHOLD = 0.6 # m — adjacent ray-points farther apart start a new cluster
|
|
MAX_CLUSTER_SPAN = 1.5 # m — wider clusters are walls / structures
|
|
RANGE_HIT_EPS = 0.05 # m — hit if range < max_range - eps
|
|
WALL_REJECT = 0.5 # m — drop detections this close to a known wall line
|
|
|
|
# Multi-peak splitting: within a single cluster, if the range profile
|
|
# has a local dip (i.e. the range increases then decreases) deeper than
|
|
# SPLIT_RANGE_GAP, the cluster is split into two detections.
|
|
SPLIT_RANGE_GAP = 0.20 # m — range increase that triggers a split
|
|
|
|
# Sheep-sized static features. A cluster centred within STATIC_REJECT of
|
|
# any of these is never a sheep.
|
|
_STATIC_FEATURES_RECT = (
|
|
( 10.0, -15.0), ( 13.0, -15.0), # gate posts
|
|
( 15.0, 15.0), ( 15.0, -15.0),
|
|
(-15.0, 15.0), (-15.0, -15.0), # field corners
|
|
)
|
|
|
|
_STATIC_FEATURES_ROUND = (
|
|
(GATE_X[0], GATE_Y),
|
|
(GATE_X[1], GATE_Y),
|
|
)
|
|
|
|
STATIC_REJECT = 0.8
|
|
|
|
|
|
def _get_static_features():
|
|
if FIELD_SHAPE == "field_round":
|
|
return _STATIC_FEATURES_ROUND
|
|
return _STATIC_FEATURES_RECT
|
|
|
|
|
|
_STATIC_FEATURES = _get_static_features()
|
|
|
|
|
|
def _in_field_region(cx: float, cy: float) -> bool:
|
|
"""Check if a detection is inside the field (with small margin)."""
|
|
if FIELD_SHAPE == "field_round":
|
|
r = math.hypot(cx, cy)
|
|
return r < FIELD_ROUND_R + 0.2
|
|
return (FIELD_X[0] - 0.2 < cx < FIELD_X[1] + 0.2 and
|
|
FIELD_Y[0] - 0.2 < cy < FIELD_Y[1] + 0.2)
|
|
|
|
|
|
def _near_wall(cx: float, cy: float, wall_reject: float = WALL_REJECT) -> bool:
|
|
"""True if the detection is too close to a wall to be a sheep."""
|
|
if FIELD_SHAPE == "field_round":
|
|
r = math.hypot(cx, cy)
|
|
return r > FIELD_ROUND_R - wall_reject
|
|
return (
|
|
cx > FIELD_X[1] - wall_reject or cx < FIELD_X[0] + wall_reject or
|
|
cy > FIELD_Y[1] - wall_reject or
|
|
(cy < FIELD_Y[0] + wall_reject and not (PEN_X[0] <= cx <= PEN_X[1]))
|
|
)
|
|
|
|
|
|
def _split_cluster_by_range(
|
|
points: list[tuple[float, float]],
|
|
range_vals: list[float],
|
|
split_range_gap: float = SPLIT_RANGE_GAP,
|
|
) -> list[list[tuple[float, float]]]:
|
|
"""Split a cluster at range-profile local maxima (gaps between sheep).
|
|
|
|
When two sheep are close, the LiDAR sees them as one arc, but the
|
|
range profile has a local peak between them (the ray passes between
|
|
the two discs). This function finds those peaks and splits.
|
|
"""
|
|
if len(points) < 4:
|
|
return [points]
|
|
# Find the minimum range in the cluster (closest point to dog).
|
|
r_min = min(range_vals)
|
|
# Find the maximum range (the dip/gap between sheep).
|
|
r_max = max(range_vals)
|
|
# If the range variation is small, it's a single target.
|
|
if r_max - r_min < split_range_gap:
|
|
return [points]
|
|
# Find the split point: the index with the maximum range.
|
|
split_idx = range_vals.index(r_max)
|
|
if split_idx <= 1 or split_idx >= len(points) - 2:
|
|
return [points]
|
|
# Split into two sub-clusters.
|
|
left = points[:split_idx]
|
|
right = points[split_idx + 1:]
|
|
# Recursively split each half.
|
|
result = []
|
|
for sub_pts, sub_ranges in [
|
|
(left, range_vals[:split_idx]),
|
|
(right, range_vals[split_idx + 1:]),
|
|
]:
|
|
if len(sub_pts) >= 1:
|
|
result.extend(_split_cluster_by_range(sub_pts, sub_ranges, split_range_gap))
|
|
return result if result else [points]
|
|
|
|
|
|
def detections_from_scan(
|
|
ranges: np.ndarray,
|
|
dog_x: float, dog_y: float, dog_heading: float,
|
|
max_range: float = LIDAR_MAX_RANGE,
|
|
detection_cfg: "DetectionConfig | None" = None,
|
|
lidar_cfg: "LidarConfig | None" = None,
|
|
) -> list[tuple[float, float]]:
|
|
"""Return list of (x, y) world-frame sheep position estimates.
|
|
|
|
Pass ``detection_cfg`` to override clustering/filtering thresholds, or
|
|
``lidar_cfg`` to inform the function of a non-default FOV (the number of
|
|
rays and FOV are inferred from the length of ``ranges`` and
|
|
``lidar_cfg.fov_rad`` respectively).
|
|
"""
|
|
# Resolve parameters — fall back to module-level constants when no cfg.
|
|
if detection_cfg is not None:
|
|
gap_thr = detection_cfg.gap_threshold
|
|
max_span = detection_cfg.max_cluster_span
|
|
hit_eps = detection_cfg.range_hit_eps
|
|
split_gap = detection_cfg.split_range_gap
|
|
wall_rej = detection_cfg.wall_reject
|
|
static_rej = detection_cfg.static_reject
|
|
else:
|
|
gap_thr = GAP_THRESHOLD
|
|
max_span = MAX_CLUSTER_SPAN
|
|
hit_eps = RANGE_HIT_EPS
|
|
split_gap = SPLIT_RANGE_GAP
|
|
wall_rej = WALL_REJECT
|
|
static_rej = STATIC_REJECT
|
|
|
|
sheep_r = lidar_cfg.sheep_radius if lidar_cfg is not None else SHEEP_RADIUS
|
|
fov = lidar_cfg.fov_rad if lidar_cfg is not None else LIDAR_FOV
|
|
if lidar_cfg is not None:
|
|
max_range = lidar_cfg.max_range
|
|
|
|
ranges = np.asarray(ranges, dtype=np.float32)
|
|
n_rays = ranges.shape[0]
|
|
if n_rays == 0:
|
|
return []
|
|
angles = ray_angles(n_rays, fov)
|
|
hit = ranges < max_range - hit_eps
|
|
|
|
world_a = dog_heading + angles
|
|
px = dog_x + ranges * np.cos(world_a)
|
|
py = dog_y + ranges * np.sin(world_a)
|
|
|
|
# Walk rays in angular order; a large jump between consecutive
|
|
# world-frame hit points closes the current cluster.
|
|
# Store (x, y, range) per hit ray for multi-peak splitting.
|
|
clusters: list[list[tuple[float, float, float]]] = []
|
|
current: list[tuple[float, float, float]] = []
|
|
prev_xy: tuple[float, float] | None = None
|
|
for i in range(n_rays):
|
|
if not bool(hit[i]):
|
|
if current:
|
|
clusters.append(current)
|
|
current = []
|
|
prev_xy = None
|
|
continue
|
|
pt = (float(px[i]), float(py[i]), float(ranges[i]))
|
|
if prev_xy is not None and math.hypot(pt[0] - prev_xy[0], pt[1] - prev_xy[1]) > gap_thr:
|
|
clusters.append(current)
|
|
current = []
|
|
current.append(pt)
|
|
prev_xy = (pt[0], pt[1])
|
|
if current:
|
|
clusters.append(current)
|
|
|
|
detections: list[tuple[float, float]] = []
|
|
for cluster in clusters:
|
|
points_xy = [(p[0], p[1]) for p in cluster]
|
|
range_vals = [p[2] for p in cluster]
|
|
|
|
# Multi-peak splitting.
|
|
if len(cluster) >= 4:
|
|
sub_clusters = _split_cluster_by_range(points_xy, range_vals, split_gap)
|
|
else:
|
|
sub_clusters = [points_xy]
|
|
|
|
for sub in sub_clusters:
|
|
if len(sub) < 1:
|
|
continue
|
|
xs = [p[0] for p in sub]
|
|
ys = [p[1] for p in sub]
|
|
cx, cy = sum(xs) / len(xs), sum(ys) / len(ys)
|
|
span = math.hypot(max(xs) - min(xs), max(ys) - min(ys))
|
|
if span > max_span:
|
|
continue
|
|
# Rays hit the front edge of the sheep; offset outward by
|
|
# sheep_radius along the dog→cluster direction.
|
|
dx, dy = cx - dog_x, cy - dog_y
|
|
d = math.hypot(dx, dy)
|
|
if d > 1e-3:
|
|
cx += sheep_r * dx / d
|
|
cy += sheep_r * dy / d
|
|
in_main = _in_field_region(cx, cy)
|
|
in_gate_strip = (PEN_X[0] - 0.2 < cx < PEN_X[1] + 0.2 and
|
|
GATE_Y - 1.0 < cy < GATE_Y + 0.2)
|
|
if not (in_main or in_gate_strip):
|
|
continue
|
|
if any(math.hypot(cx - fx, cy - fy) < static_rej
|
|
for fx, fy in _STATIC_FEATURES):
|
|
continue
|
|
if _near_wall(cx, cy, wall_rej):
|
|
continue
|
|
detections.append((cx, cy))
|
|
return detections
|