Skip to content

Commit 3605783

Browse files
kaiaaiclaude
andcommitted
contour_follower: fit a line to the surface instead of picking the nearest beam
Live capture showed the distance loop holding +-0.03 m while the bearing error thrashed +-20 deg frame to frame, with heading error mirroring it exactly -- the controller was steering on noise. Cause: near the perpendicular the range is almost flat. At a 0.2 m standoff, swinging 20 deg changes the range by 1.3 cm while the beam-to-beam scatter is about 2 cm, so the ARG-min (which beam is nearest) is essentially random across a wide arc. min() over noisy beams is also a biased distance, which is part of why it hugged closer than the target. Now _boundary() seeds on the nearest beam, grows the contiguous surface around it, and total-least-squares fits a line to those points, reporting the fitted perpendicular distance and the bearing to it. Every point on the surface contributes, so the noise averages down and the wall angle comes out directly. Checked against synthetic data: exact on clean input, and with 2 cm noise it recovers the bearing to 0.5 deg where arg-min gave +-20 deg. Falls back to the nearest beam when fewer than min_fit_points survive. The fitted segment is drawn in ~/debug_markers so the fit is visible in RViz. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
1 parent 0229389 commit 3605783

1 file changed

Lines changed: 89 additions & 17 deletions

File tree

src/oomwoo_clean/oomwoo_clean/contour_follower_node.py

Lines changed: 89 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -16,10 +16,11 @@
1616
Reactive LiDAR contour follower: trace an obstacle's boundary at a fixed standoff.
1717
1818
The proactive, any-shape generalization of the bumper-based wall_clean. Off the
19-
LiDAR it finds the NEAREST boundary point in a forward-biased sector on the follow
20-
side (default right) and servos two errors -- standoff distance and the point's
21-
bearing (want it abeam, -90 deg). That one law handles straight walls and CONCAVE
22-
inside corners. CONVEX outside corners get an explicit recovery: when the near
19+
LiDAR it isolates the followed surface in a forward-biased sector on the follow
20+
side (default right), FITS A LINE to it, and servos two errors -- the fitted
21+
perpendicular distance and the bearing to it (want it abeam, -90 deg). That one
22+
law handles straight walls and CONCAVE inside corners. CONVEX outside corners
23+
get an explicit recovery: when the near
2324
boundary vanishes (range jumps, or nothing left in the sector) the follower stops
2425
trusting the far reading and ARCS toward the follow side at ~standoff radius until
2526
it re-acquires -- "lose the wall, curve toward it". Left-follow is the mirror
@@ -68,6 +69,8 @@
6869
'sector_min_deg': -170.0, # follow-side + forward window (right-follow)
6970
'sector_max_deg': 20.0,
7071
'max_follow_range_m': 1.0, # ignore boundaries farther than this
72+
'fit_gap_m': 0.10, # max step between adjacent points on one surface
73+
'min_fit_points': 6, # below this, fall back to the nearest beam
7174
'bearing_ref_deg': -90.0, # want the nearest point abeam (right)
7275
'k_approach': 2.0, # rad of approach angle per m of standoff error
7376
'alpha_max_deg': 40.0, # cap on the approach angle (far-wall approach)
@@ -111,6 +114,7 @@ def __init__(self) -> None:
111114
self._dbg_d = None # last nearest pick, for debug markers
112115
self._dbg_b = None
113116
self._t_log = None # last diagnostic log time
117+
self._dbg_fit = None # fitted segment endpoints, for markers
114118

115119
latched = QoSProfile(
116120
depth=1, history=QoSHistoryPolicy.KEEP_LAST,
@@ -166,22 +170,79 @@ def _on_enable(self, msg: Bool) -> None:
166170
self.arc_swept = 0.0
167171
self._set_state('ALIGN')
168172

169-
def _nearest(self, msg, smin, smax, max_r):
170-
"""Nearest valid boundary (d, bearing) in [smin, smax], follow-side frame."""
171-
best_d = None
172-
best_b = None
173+
def _boundary(self, msg, smin, smax, max_r):
174+
"""
175+
Fit the followed surface; return (perpendicular distance, bearing, n).
176+
177+
Seeds on the nearest beam in the sector, grows the contiguous surface
178+
around it, then total-least-squares fits a line to those points and
179+
reports the perpendicular distance to that line and the bearing to it.
180+
181+
Fitting rather than just taking the nearest beam matters. Near the
182+
perpendicular the range is almost flat -- at 0.2 m, swinging 20 deg
183+
changes it by 1.3 cm, while the beam-to-beam scatter is around 2 cm -- so
184+
the ARG-min (which beam is closest) is essentially random over a wide arc,
185+
and min() over noisy beams is a biased distance. The fit uses every point
186+
on the surface, so the noise averages down and the wall angle falls out
187+
directly instead of being inferred from a single beam.
188+
"""
189+
count = len(msg.ranges)
190+
pts = [None] * count
191+
seed = None
192+
seed_r = None
173193
for i, r in enumerate(msg.ranges):
174194
if not math.isfinite(r) or r < msg.range_min or r > max_r:
175195
continue
176196
b = self.side * math.remainder(
177197
msg.angle_min + i * msg.angle_increment, TWO_PI)
178-
if b < smin or b > smax or (best_d is not None and r >= best_d):
198+
if b < smin or b > smax:
179199
continue
180-
best_d = r
181-
best_b = b
182-
self._dbg_d = best_d
183-
self._dbg_b = best_b
184-
return best_d, best_b
200+
pts[i] = (r * math.cos(b), r * math.sin(b), r, b)
201+
if seed_r is None or r < seed_r:
202+
seed, seed_r = i, r
203+
if seed is None:
204+
self._dbg_d = self._dbg_b = self._dbg_fit = None
205+
return None, None, 0
206+
207+
# grow the contiguous surface either way from the seed
208+
gap = self._p('fit_gap_m')
209+
keep = [seed]
210+
for step in (1, -1):
211+
j = seed
212+
while True:
213+
k = (j + step) % count
214+
if k == seed or pts[k] is None:
215+
break
216+
if math.hypot(pts[k][0] - pts[j][0],
217+
pts[k][1] - pts[j][1]) > gap:
218+
break
219+
keep.append(k)
220+
j = k
221+
sel = [pts[k] for k in keep]
222+
223+
if len(sel) < int(self._p('min_fit_points')):
224+
self._dbg_d, self._dbg_b = seed_r, pts[seed][3]
225+
self._dbg_fit = None
226+
return seed_r, pts[seed][3], len(sel)
227+
228+
m = float(len(sel))
229+
cx = sum(p[0] for p in sel) / m
230+
cy = sum(p[1] for p in sel) / m
231+
sxx = sum((p[0] - cx) ** 2 for p in sel) / m
232+
syy = sum((p[1] - cy) ** 2 for p in sel) / m
233+
sxy = sum((p[0] - cx) * (p[1] - cy) for p in sel) / m
234+
theta = 0.5 * math.atan2(2.0 * sxy, sxx - syy) # line direction
235+
nx, ny = -math.sin(theta), math.cos(theta) # unit normal
236+
dist = cx * nx + cy * ny # signed, origin to line
237+
if dist < 0.0:
238+
nx, ny, dist = -nx, -ny, -dist
239+
240+
ct, st = math.cos(theta), math.sin(theta)
241+
ts = [(p[0] - cx) * ct + (p[1] - cy) * st for p in sel]
242+
self._dbg_fit = ((cx + min(ts) * ct, cy + min(ts) * st),
243+
(cx + max(ts) * ct, cy + max(ts) * st))
244+
self._dbg_d, self._dbg_b = dist, math.atan2(ny, nx)
245+
return dist, math.atan2(ny, nx), len(sel)
185246

186247
def _on_scan(self, msg: LaserScan) -> None:
187248
t = msg.header.stamp.sec + msg.header.stamp.nanosec * 1e-9
@@ -208,7 +269,7 @@ def _step(self, msg, b_ref, smin, smax, max_r, dt) -> None:
208269
return
209270

210271
if self.state == 'FOLLOW':
211-
d, b = self._nearest(msg, smin, smax, max_r)
272+
d, b, _ = self._boundary(msg, smin, smax, max_r)
212273
jumped = (self.prev_d is not None and d is not None
213274
and (d - self.prev_d) > self._p('convex_jump_m'))
214275
if d is None or jumped:
@@ -247,7 +308,7 @@ def _arc(self, msg, smin, smax, max_r, b_ref, dt) -> None:
247308
v = max(self._p('v_min'), 0.5 * self._p('v_nominal'))
248309
omega = -v / max(self._p('convex_arc_radius_m'), 1e-3) # toward follow side
249310
self.arc_swept += abs(omega) * dt
250-
d, b = self._nearest(msg, smin, smax, max_r)
311+
d, b, _ = self._boundary(msg, smin, smax, max_r)
251312
if d is not None and d <= self._p('standoff_m') + self._p('reacquire_margin_m'):
252313
self.prev_d = d
253314
self._set_state('FOLLOW')
@@ -262,7 +323,7 @@ def _arc(self, msg, smin, smax, max_r, b_ref, dt) -> None:
262323

263324
def _align(self, msg, b_ref, max_r) -> None:
264325
# rotate in place to bring the nearest obstacle abeam on the follow side
265-
d, b = self._nearest(msg, -math.pi, math.pi, max_r)
326+
d, b, _ = self._boundary(msg, -math.pi, math.pi, max_r)
266327
if d is None:
267328
self._set_cmd(0.0, self.side * self._p('align_omega')) # search
268329
return
@@ -328,6 +389,17 @@ def _pub_markers(self, b_ref, smin, smax) -> None:
328389
tgt.color.a = 0.9
329390
tgt.pose.position = self._pt(self._p('standoff_m'), s * b_ref)
330391
arr.markers.append(tgt)
392+
if self._dbg_fit is not None:
393+
fit = self._mk(5, Marker.LINE_LIST, stamp)
394+
fit.scale.x = 0.012
395+
fit.color.r = 1.0
396+
fit.color.b = 1.0
397+
fit.color.a = 0.9
398+
for px, py in self._dbg_fit:
399+
p = Point()
400+
p.x, p.y, p.z = px, s * py, 0.0
401+
fit.points.append(p)
402+
arr.markers.append(fit)
331403
sec = self._mk(3, Marker.LINE_LIST, stamp)
332404
sec.scale.x = 0.006
333405
sec.color.r = 1.0

0 commit comments

Comments
 (0)